feat: implement barangay system phases 2-14
Complete adaptation from BukidBountyApp to Philippine barangay governance: - Barangay models: Resident, Household, HouseholdMember, Blotter, BlotterHearing, DocumentRequest, RequestPayment, RequestType, BarangayProject, BarangayBudget - Controllers: ResidentController, HouseholdController, BlotterController, BlotterHearingController, DocumentRequestController, RequestTypeController, ProjectController, BudgetController, QRPHController, AdminConsoleController, UserController, FileController, ChapterController, LoginController - Vue pages: Home, ManageResidents, ResidentProfile, ManageHouseholds, ManageBlotters, BlotterDetail, RequestDocument, ManageDocumentRequests, DocumentRequestDetail, ManageRequestTypes, ManageProjects, BudgetLedger, AdminConsole - Barangay roles: PunongBarangay, Kagawad, Secretary, Treasurer, SK, Tanod, BHW, Staff, Resident - UserPermissions matrix rewritten with barangay-specific permission mappings - VueRouteMap replaced with barangay SPA routes - UserActions enum references corrected across all controllers - Removed all market/cooperative/POS/subscription code and models
This commit is contained in:
@@ -1,511 +0,0 @@
|
||||
<template>
|
||||
<div class="accounting-dashboard min-vh-100 bg-light pb-5">
|
||||
<!-- Premium Header -->
|
||||
<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-file-invoice-dollar fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="fw-bold text-white mb-0">Accounting Dashboard</h2>
|
||||
<p class="text-white-50 small text-uppercase ls-wide mt-1">
|
||||
{{ isBig3 ? 'Financial Records & Reports' : 'Store Financial Records' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/manage-accounts" class="btn btn-light btn-sm fw-semibold shadow-sm">
|
||||
<i class="fas fa-sitemap me-1"></i> Manage accounts
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="card border-0 shadow-lg rounded-4 bg-white overflow-hidden">
|
||||
<!-- Tabs Header -->
|
||||
<div class="card-header bg-white border-bottom p-0">
|
||||
<ul class="nav nav-tabs nav-justified border-0" id="accountingTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link py-3 fw-bold border-0"
|
||||
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'daily' }"
|
||||
@click="activeTab = 'daily'"
|
||||
>
|
||||
<i class="fas fa-calendar-day me-2"></i> Daily Entry
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link py-3 fw-bold border-0"
|
||||
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'transactions' }"
|
||||
@click="activeTab = 'transactions'"
|
||||
>
|
||||
<i class="fas fa-list me-2"></i> Transactions
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
class="nav-link py-3 fw-bold border-0"
|
||||
:class="{ 'active text-primary border-bottom border-primary border-3': activeTab === 'monthly' }"
|
||||
@click="activeTab = 'monthly'"
|
||||
>
|
||||
<i class="fas fa-table me-2"></i> Monthly Matrix
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
|
||||
<!-- Daily Entry Tab -->
|
||||
<div v-if="activeTab === 'daily'" class="animate-fade-in">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-calendar-alt text-primary me-2"></i> Date Selection</h4>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<input type="date" v-model="selectedDate" class="form-control form-control-lg fw-bold text-center border-primary shadow-sm" style="max-width: 200px;" @change="fetchDailyData" />
|
||||
<button @click="fetchDailyData" class="btn btn-primary" :disabled="loading.daily">
|
||||
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.daily }"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading.daily" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="mt-2 text-muted fw-bold">Loading accounts...</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="alert alert-info border-0 rounded-3 small">
|
||||
<i class="fas fa-info-circle me-2"></i> Enter amounts for the selected date. Leave empty or 0 if no transaction occurred.
|
||||
</div>
|
||||
|
||||
<!-- Grouping Leaf Accounts by their immediate parent -->
|
||||
<div class="row g-4 mt-2">
|
||||
<div v-for="(accounts, parentName) in groupedLeafAccounts" :key="parentName" class="col-md-6 col-lg-4">
|
||||
<div class="card h-100 border-0 shadow-sm rounded-3 bg-light">
|
||||
<div class="card-header bg-dark text-white fw-bold border-0 py-2 rounded-top-3">
|
||||
{{ parentName }}
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<div v-for="acc in accounts" :key="acc.id" class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted mb-1">{{ acc.name }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white text-muted">₱</span>
|
||||
<input type="number" class="form-control fw-bold" v-model="dailyEntries[acc.id].amount" placeholder="0.00" step="0.01">
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm mt-1" v-model="dailyEntries[acc.id].notes" placeholder="Notes (optional)...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-end mt-4 pt-3 border-top">
|
||||
<button @click="saveDailyData" class="btn btn-success btn-lg px-5 rounded-pill shadow fw-bold" :disabled="savingDaily">
|
||||
<i class="fas fa-save me-2" :class="{ 'fa-spin': savingDaily }"></i>
|
||||
{{ savingDaily ? 'Saving...' : 'Save Daily Record' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions List Tab -->
|
||||
<div v-if="activeTab === 'transactions'" class="animate-fade-in">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
|
||||
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-list text-primary me-2"></i> Transaction History</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="date" v-model="filters.date_from" class="form-control form-control-sm" title="From Date">
|
||||
<input type="date" v-model="filters.date_to" class="form-control form-control-sm" title="To Date">
|
||||
<button @click="fetchTransactions" class="btn btn-primary btn-sm px-3"><i class="fas fa-search"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive bg-white rounded-3 shadow-sm border">
|
||||
<table class="table table-hover align-middle mb-0 text-nowrap">
|
||||
<thead class="bg-light text-muted small fw-bold text-uppercase">
|
||||
<tr>
|
||||
<th class="ps-3">Date</th>
|
||||
<th>Account</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Notes</th>
|
||||
<th class="text-end pe-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading.transactions" class="text-center">
|
||||
<td colspan="6" class="py-4"><div class="spinner-border text-primary spinner-border-sm"></div> Loading...</td>
|
||||
</tr>
|
||||
<tr v-else-if="transactions.length === 0" class="text-center">
|
||||
<td colspan="6" class="py-4 text-muted">No transactions found</td>
|
||||
</tr>
|
||||
<tr v-for="txn in transactions" :key="txn.id">
|
||||
<td class="ps-3 fw-bold text-dark">{{ formatDate(txn.transaction_date) }}</td>
|
||||
<td>
|
||||
<div class="d-flex flex-column">
|
||||
<span class="fw-bold">{{ txn.account?.name || 'Unknown' }}</span>
|
||||
<span class="small text-muted">{{ txn.account?.parent?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="txn.flow === 'INCOME' ? 'bg-success' : 'bg-danger'">{{ txn.flow }}</span>
|
||||
</td>
|
||||
<td class="fw-bold" :class="txn.flow === 'INCOME' ? 'text-success' : 'text-danger'">
|
||||
{{ txn.flow === 'INCOME' ? '+' : '-' }} ₱{{ Number(txn.amount).toLocaleString(undefined, {minimumFractionDigits: 2}) }}
|
||||
</td>
|
||||
<td class="text-muted small text-truncate" style="max-width: 150px;" :title="txn.notes">{{ txn.notes || '-' }}</td>
|
||||
<td class="text-end pe-3">
|
||||
<button @click="deleteTransaction(txn.id)" class="btn btn-outline-danger btn-sm rounded-circle"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monthly Matrix Tab -->
|
||||
<div v-if="activeTab === 'monthly'" class="animate-fade-in">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4 gap-3">
|
||||
<h4 class="fw-bold mb-0 text-dark"><i class="fas fa-table text-primary me-2"></i> Monthly Overview</h4>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select v-model="reportMonth" class="form-select form-select-sm fw-bold border-primary shadow-sm">
|
||||
<option v-for="(m, i) in months" :value="i+1" :key="i">{{ m }}</option>
|
||||
</select>
|
||||
<input type="number" v-model="reportYear" class="form-control form-control-sm fw-bold border-primary shadow-sm" style="width: 80px;">
|
||||
<button @click="fetchMonthlyReport" class="btn btn-primary btn-sm px-3 fw-bold"><i class="fas fa-sync-alt" :class="{ 'fa-spin': loading.monthly }"></i> Load</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading.monthly" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="mt-2 text-muted fw-bold">Generating Report...</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="topNode in accountTree" :key="topNode.id" class="mb-5 animate-slide-up">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h5 class="fw-black text-primary ls-tight mb-0">
|
||||
<i class="fas fa-table me-2"></i> {{ topNode.name }}
|
||||
</h5>
|
||||
<div class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill small fw-bold">
|
||||
{{ reportMonthName }} {{ reportYear }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive shadow-sm rounded border matrix-table-container mb-3">
|
||||
<table class="table table-bordered table-sm mb-0 text-nowrap matrix-table" style="font-size: 0.82rem;">
|
||||
<thead class="bg-dark text-white text-center align-middle sticky-top">
|
||||
<tr>
|
||||
<th rowspan="2" class="bg-primary text-white sticky-col" style="width: 60px; z-index: 5;">Date</th>
|
||||
<template v-for="mid in topNode.children" :key="mid.id">
|
||||
<th :colspan="countLeafs(mid)" class="bg-dark text-white border-white-10">{{ mid.name }}</th>
|
||||
</template>
|
||||
<th rowspan="2" class="bg-success text-white" style="width: 100px;">TOTAL</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<!-- Render Leaf Names -->
|
||||
<template v-for="mid in topNode.children" :key="'sub_'+mid.id">
|
||||
<template v-if="mid.children && mid.children.length > 0">
|
||||
<th v-for="leaf in mid.children" :key="leaf.id" class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">
|
||||
{{ leaf.name }}
|
||||
</th>
|
||||
</template>
|
||||
<template v-else>
|
||||
<th class="bg-secondary text-white fw-normal border-white-10" style="min-width: 80px;">{{ mid.name }}</th>
|
||||
</template>
|
||||
</template>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in matrixDays" :key="day" class="page-row">
|
||||
<td class="text-center fw-bold bg-light sticky-col">{{ day }}</td>
|
||||
<template v-for="mid in topNode.children" :key="'td_mid_'+mid.id">
|
||||
<template v-if="mid.children && mid.children.length > 0">
|
||||
<td v-for="leaf in mid.children" :key="'td_leaf_'+leaf.id" class="text-end px-2">
|
||||
{{ formatAmount(matrixData[day][leaf.id]) }}
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="text-end px-2">{{ formatAmount(matrixData[day][mid.id]) }}</td>
|
||||
</template>
|
||||
</template>
|
||||
<td class="text-end fw-bold bg-soft-success text-success px-2">
|
||||
{{ formatAmount(getRowTotalForCategory(day, topNode)) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="bg-light fw-bold sticky-bottom">
|
||||
<tr>
|
||||
<td class="text-center text-primary sticky-col">TOTAL</td>
|
||||
<template v-for="mid in topNode.children" :key="'tf_mid_'+mid.id">
|
||||
<template v-if="mid.children && mid.children.length > 0">
|
||||
<td v-for="leaf in mid.children" :key="'tf_leaf_'+leaf.id" class="text-end text-primary px-2">
|
||||
{{ formatAmount(matrixTotals[leaf.id]) }}
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="text-end text-primary px-2">{{ formatAmount(matrixTotals[mid.id]) }}</td>
|
||||
</template>
|
||||
</template>
|
||||
<td class="text-end text-success px-2">
|
||||
{{ formatAmount(getColumnTotalForCategory(topNode)) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { useAuth } from '@/composables/Core/useAuth';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
uiStore.setPageTitle('Accounting Dashboard');
|
||||
|
||||
const { isUltimate, isSuperOperator, isOperator } = useAuth();
|
||||
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
|
||||
|
||||
const activeTab = ref('daily');
|
||||
const selectedDate = ref(new Date().toISOString().split('T')[0]);
|
||||
|
||||
const leafAccounts = ref([]);
|
||||
const groupedLeafAccounts = ref({});
|
||||
const dailyEntries = ref({});
|
||||
const savingDaily = ref(false);
|
||||
|
||||
const transactions = ref([]);
|
||||
const filters = ref({ date_from: '', date_to: '' });
|
||||
|
||||
const accountTree = ref([]);
|
||||
const reportMonth = ref(new Date().getMonth() + 1);
|
||||
const reportYear = ref(new Date().getFullYear());
|
||||
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const matrixDays = ref(0);
|
||||
const matrixData = ref({});
|
||||
const matrixTotals = ref({});
|
||||
|
||||
const loading = ref({
|
||||
daily: false,
|
||||
transactions: false,
|
||||
monthly: false,
|
||||
tree: false
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchLeafAccounts();
|
||||
fetchAccountTree();
|
||||
});
|
||||
|
||||
const fetchLeafAccounts = async () => {
|
||||
loading.value.daily = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/leaf', {});
|
||||
leafAccounts.value = res.data.data;
|
||||
|
||||
// Group them
|
||||
const grouped = {};
|
||||
const entries = {};
|
||||
leafAccounts.value.forEach(acc => {
|
||||
const parentName = acc.parent ? acc.parent.name : 'Uncategorized';
|
||||
if (!grouped[parentName]) grouped[parentName] = [];
|
||||
grouped[parentName].push(acc);
|
||||
entries[acc.id] = { amount: '', notes: '' };
|
||||
});
|
||||
groupedLeafAccounts.value = grouped;
|
||||
dailyEntries.value = entries;
|
||||
|
||||
// Once structure is ready, fetch today's data
|
||||
await fetchDailyData();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value.daily = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDailyData = async () => {
|
||||
loading.value.daily = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/daily', { date: selectedDate.value });
|
||||
const existing = res.data.data;
|
||||
|
||||
// Reset entries first
|
||||
Object.keys(dailyEntries.value).forEach(id => {
|
||||
dailyEntries.value[id] = { amount: '', notes: '' };
|
||||
});
|
||||
|
||||
// Fill with existing
|
||||
Object.keys(existing).forEach(id => {
|
||||
if(dailyEntries.value[id]) {
|
||||
dailyEntries.value[id].amount = existing[id].amount;
|
||||
dailyEntries.value[id].notes = existing[id].notes;
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value.daily = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveDailyData = async () => {
|
||||
savingDaily.value = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/daily/save', {
|
||||
date: selectedDate.value,
|
||||
entries: dailyEntries.value
|
||||
});
|
||||
if(res.data.success) {
|
||||
if (window.toastr) window.toastr.success('Daily transactions saved successfully!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (window.toastr) window.toastr.error('Failed to save transactions.');
|
||||
} finally {
|
||||
savingDaily.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
loading.value.transactions = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/transactions', filters.value);
|
||||
transactions.value = res.data.data.data; // paginated
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value.transactions = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTransaction = async (id) => {
|
||||
if(!confirm("Are you sure you want to delete this transaction?")) return;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/transactions/delete', { id });
|
||||
if(res.data.success) {
|
||||
if (window.toastr) window.toastr.success('Transaction deleted');
|
||||
fetchTransactions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAccountTree = async () => {
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/tree', {});
|
||||
accountTree.value = res.data.data;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMonthlyReport = async () => {
|
||||
loading.value.monthly = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/reports/monthly', {
|
||||
month: reportMonth.value,
|
||||
year: reportYear.value
|
||||
});
|
||||
matrixDays.value = res.data.days_in_month;
|
||||
matrixData.value = res.data.matrix;
|
||||
matrixTotals.value = res.data.column_totals;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading.value.monthly = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reportMonthName = computed(() => months[reportMonth.value - 1]);
|
||||
|
||||
// Utils for Matrix
|
||||
const countLeafs = (node) => {
|
||||
if (!node.children || node.children.length === 0) return 1;
|
||||
let count = 0;
|
||||
node.children.forEach(child => {
|
||||
count += countLeafs(child);
|
||||
});
|
||||
return count;
|
||||
};
|
||||
|
||||
const getLeafIds = (node) => {
|
||||
if (!node.children || node.children.length === 0) return [node.id];
|
||||
let ids = [];
|
||||
node.children.forEach(child => {
|
||||
ids = ids.concat(getLeafIds(child));
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const getRowTotalForCategory = (day, topNode) => {
|
||||
const leafIds = getLeafIds(topNode);
|
||||
let total = 0;
|
||||
leafIds.forEach(id => {
|
||||
total += parseFloat(matrixData.value[day][id] || 0);
|
||||
});
|
||||
return total;
|
||||
};
|
||||
|
||||
const getColumnTotalForCategory = (topNode) => {
|
||||
const leafIds = getLeafIds(topNode);
|
||||
let total = 0;
|
||||
leafIds.forEach(id => {
|
||||
total += parseFloat(matrixTotals.value[id] || 0);
|
||||
});
|
||||
return total;
|
||||
};
|
||||
|
||||
const formatAmount = (val) => {
|
||||
if (!val || val == 0) return '-';
|
||||
return Number(val).toLocaleString(undefined, {minimumFractionDigits: 2});
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const d = new Date(dateString);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-primary-gradient {
|
||||
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
|
||||
}
|
||||
.matrix-table-container {
|
||||
max-height: 65vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.matrix-table th, .matrix-table td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.matrix-table .sticky-col {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
box-shadow: inset -1px 0 0 rgba(0,0,0,0.1);
|
||||
}
|
||||
.matrix-table .sticky-top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
.matrix-table .sticky-bottom {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
box-shadow: inset 0 1px 0 rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,466 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
|
||||
usePageTitle('Add Products to Store');
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, default: null },
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const STEP = { PICK: 1, EDIT: 2 };
|
||||
const step = ref(STEP.PICK);
|
||||
|
||||
const storeHash = computed(() => props.target);
|
||||
const store = ref(null);
|
||||
|
||||
const loadingProducts = ref(false);
|
||||
const loadingStore = ref(false);
|
||||
const submitting = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
const allProducts = ref([]);
|
||||
const search = ref('');
|
||||
const selected = ref({});
|
||||
const rows = ref([]);
|
||||
|
||||
const bulkPrice = ref('');
|
||||
const bulkAvailable = ref('');
|
||||
|
||||
const firstPhoto = (v) => Array.isArray(v) ? (v[0] || '') : (v || '');
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return allProducts.value;
|
||||
return allProducts.value.filter((p) =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
(p.category || '').toLowerCase().includes(q) ||
|
||||
(p.subcategory || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => Object.values(selected.value).filter(Boolean).length);
|
||||
|
||||
const fetchStore = async () => {
|
||||
if (!storeHash.value) return;
|
||||
loadingStore.value = true;
|
||||
try {
|
||||
const { data } = await axios.post('/View/Store/Details/data', { target: storeHash.value });
|
||||
if (data?.success) store.value = data.data;
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch store details', e);
|
||||
} finally {
|
||||
loadingStore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
loadingProducts.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data } = await axios.post('/Products/GlobalList', {});
|
||||
if (data?.success && Array.isArray(data.products)) {
|
||||
allProducts.value = data.products;
|
||||
} else {
|
||||
error.value = 'Failed to load products';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Failed to load products. Please try again.';
|
||||
} finally {
|
||||
loadingProducts.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProduct = (hash) => {
|
||||
selected.value = { ...selected.value, [hash]: !selected.value[hash] };
|
||||
};
|
||||
|
||||
const selectAllFiltered = () => {
|
||||
const next = { ...selected.value };
|
||||
for (const p of filteredProducts.value) next[p.hashkey] = true;
|
||||
selected.value = next;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selected.value = {};
|
||||
};
|
||||
|
||||
const proceedToEdit = () => {
|
||||
const picks = allProducts.value.filter((p) => selected.value[p.hashkey]);
|
||||
if (picks.length === 0) {
|
||||
error.value = 'Pick at least one product to continue.';
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
rows.value = picks.map((p) => ({
|
||||
hashkey: p.hashkey,
|
||||
name: p.name,
|
||||
photourl: p.photourl,
|
||||
unitname: p.unitname,
|
||||
category: p.category,
|
||||
price: parseFloat(p.price) || 0,
|
||||
available: parseInt(p.available) || 0,
|
||||
global_price: parseFloat(p.price) || 0,
|
||||
global_available: parseInt(p.available) || 0,
|
||||
description: p.description || '',
|
||||
}));
|
||||
step.value = STEP.EDIT;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const backToPick = () => {
|
||||
step.value = STEP.PICK;
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const applyBulkPrice = () => {
|
||||
const v = parseFloat(bulkPrice.value);
|
||||
if (isNaN(v) || v < 0) return;
|
||||
rows.value = rows.value.map((r) => ({ ...r, price: v }));
|
||||
};
|
||||
|
||||
const applyBulkAvailable = () => {
|
||||
const v = parseInt(bulkAvailable.value);
|
||||
if (isNaN(v) || v < 0) return;
|
||||
rows.value = rows.value.map((r) => ({ ...r, available: v }));
|
||||
};
|
||||
|
||||
const removeRow = (hash) => {
|
||||
rows.value = rows.value.filter((r) => r.hashkey !== hash);
|
||||
selected.value = { ...selected.value, [hash]: false };
|
||||
if (rows.value.length === 0) backToPick();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting.value) return;
|
||||
if (!storeHash.value) {
|
||||
error.value = 'No store specified.';
|
||||
return;
|
||||
}
|
||||
for (const r of rows.value) {
|
||||
if (!(r.price >= 0)) {
|
||||
error.value = `Invalid price for "${r.name}".`;
|
||||
return;
|
||||
}
|
||||
if (!(r.available >= 0)) {
|
||||
error.value = `Invalid availability for "${r.name}".`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
const failures = [];
|
||||
for (const r of rows.value) {
|
||||
try {
|
||||
await axios.post('/Products/AssignToStore/', {
|
||||
target: r.hashkey,
|
||||
TargetStore: storeHash.value,
|
||||
price: parseFloat(r.price),
|
||||
available: parseInt(r.available),
|
||||
description: r.description || '',
|
||||
});
|
||||
} catch (e) {
|
||||
failures.push(r.name);
|
||||
}
|
||||
}
|
||||
submitting.value = false;
|
||||
|
||||
if (failures.length === rows.value.length) {
|
||||
error.value = 'Failed to add any product. Please try again.';
|
||||
return;
|
||||
}
|
||||
|
||||
modal.quickDismiss({
|
||||
title: 'Products Added',
|
||||
body: failures.length
|
||||
? `Added ${rows.value.length - failures.length} of ${rows.value.length}. Failed: ${failures.join(', ')}.`
|
||||
: `Added ${rows.value.length} product(s) to ${store.value?.name || 'your store'}.`,
|
||||
onShown: () => {
|
||||
setTimeout(() => navigate({ page: 'ViewStoreMarket', props: { target: storeHash.value } }), 1100);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (!storeHash.value) {
|
||||
error.value = 'No store specified. Pick a store from Manage Stores.';
|
||||
return;
|
||||
}
|
||||
fetchStore();
|
||||
fetchProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="add-products-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<BackButton />
|
||||
<h3 class="fw_6 mb-0">Add Products to Store</h3>
|
||||
</div>
|
||||
|
||||
<CardSimple class="mb-3" :is-premium="false">
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="text-muted smallest">Target store</div>
|
||||
<div class="fw_6">{{ store?.name || (loadingStore ? 'Loading…' : '—') }}</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span v-if="step === STEP.PICK" class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill">
|
||||
Step 1 of 2 · Pick products
|
||||
</span>
|
||||
<span v-else class="badge bg-soft-primary text-primary px-3 py-2 rounded-pill">
|
||||
Step 2 of 2 · Set price & stock
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div v-if="error" class="alert alert-danger mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Picker -->
|
||||
<div v-if="step === STEP.PICK">
|
||||
<CardSimple class="mb-3" :is-premium="false">
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
|
||||
<div class="flex-grow-1" style="min-width: 220px;">
|
||||
<input v-model="search" type="text" class="form-control"
|
||||
placeholder="Search products by name, category…" />
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary rounded-pill" @click="selectAllFiltered">
|
||||
Select all shown
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary rounded-pill" @click="clearSelection"
|
||||
:disabled="selectedCount === 0">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div v-if="loadingProducts" class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<div v-else-if="filteredProducts.length === 0" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-box-open fa-3x opacity-25 mb-3"></i>
|
||||
<p class="mb-0">No products match your search.</p>
|
||||
</div>
|
||||
<div v-else class="row g-2">
|
||||
<div v-for="p in filteredProducts" :key="p.hashkey" class="col-12 col-sm-6 col-lg-4">
|
||||
<div class="product-pick-card" :class="{ picked: selected[p.hashkey] }"
|
||||
@click="toggleProduct(p.hashkey)">
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
<div class="product-thumb">
|
||||
<FileImage :src="firstPhoto(p.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>
|
||||
<div class="flex-grow-1 min-w-0">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="fw_6 text-truncate" :title="p.name">{{ p.name }}</div>
|
||||
</div>
|
||||
<div class="text-muted smallest text-truncate">
|
||||
{{ p.category }}<span v-if="p.subcategory"> · {{ p.subcategory }}</span>
|
||||
</div>
|
||||
<div class="smallest">
|
||||
₱{{ p.price }} <span class="text-muted">/ {{ p.unitname || 'unit' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" :checked="!!selected[p.hashkey]"
|
||||
@click.stop="toggleProduct(p.hashkey)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky-bottom-bar mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
<strong>{{ selectedCount }}</strong> selected
|
||||
</div>
|
||||
<button class="btn btn-primary rounded-pill px-4" :disabled="selectedCount === 0"
|
||||
@click="proceedToEdit">
|
||||
Continue <i class="fas fa-arrow-right ms-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Batch edit -->
|
||||
<div v-else>
|
||||
<CardSimple class="mb-3">
|
||||
<div class="fw_6 mb-3">Bulk apply</div>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-sm-5">
|
||||
<label class="form-label smallest text-muted mb-1">Set all prices (₱)</label>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input v-model="bulkPrice" type="number" min="0" step="0.01" class="form-control"
|
||||
placeholder="e.g. 50" />
|
||||
<button class="btn btn-primary rounded-pill flex-shrink-0" @click="applyBulkPrice">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-5">
|
||||
<label class="form-label smallest text-muted mb-1">Set all availability</label>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input v-model="bulkAvailable" type="number" min="0" step="1" class="form-control"
|
||||
placeholder="e.g. 100" />
|
||||
<button class="btn btn-primary rounded-pill flex-shrink-0" @click="applyBulkAvailable">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-sm-2 d-flex align-items-end">
|
||||
<button class="btn btn-outline-secondary rounded-pill w-100" @click="backToPick">
|
||||
<i class="fas fa-arrow-left me-1"></i> Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Product</th>
|
||||
<th style="width: 160px;">Price (₱)</th>
|
||||
<th style="width: 160px;">Available</th>
|
||||
<th style="width: 60px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in rows" :key="r.hashkey">
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="product-thumb-sm">
|
||||
<FileImage :src="firstPhoto(r.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>
|
||||
<div class="min-w-0">
|
||||
<div class="fw_6 text-truncate" :title="r.name">{{ r.name }}</div>
|
||||
<div class="text-muted smallest">
|
||||
global ₱{{ r.global_price }} · {{ r.global_available }} {{ r.unitname || 'unit' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input v-model.number="r.price" type="number" min="0" step="0.01"
|
||||
class="form-control form-control-sm" />
|
||||
</td>
|
||||
<td>
|
||||
<input v-model.number="r.available" type="number" min="0" step="1"
|
||||
class="form-control form-control-sm" />
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-icon btn-outline-danger" title="Remove"
|
||||
@click="removeRow(r.hashkey)">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="sticky-bottom-bar mt-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small"><strong>{{ rows.length }}</strong> product(s) to add</div>
|
||||
<button class="btn btn-primary rounded-pill px-4" :disabled="submitting || rows.length === 0"
|
||||
@click="submit">
|
||||
<span v-if="submitting"><LoadingSpinner small /></span>
|
||||
<span v-else><i class="fas fa-check me-1"></i> Add to Store</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.product-pick-card {
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
background: var(--bg-card, #fff);
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
.row.g-2 {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.product-pick-card:hover {
|
||||
border-color: var(--primary, #4caf50);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.product-pick-card.picked {
|
||||
border-color: var(--primary, #4caf50);
|
||||
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
|
||||
.product-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.product-thumb :deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.product-thumb-sm {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.product-thumb-sm :deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sticky-bottom-bar {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
background: var(--bg-card, #fff);
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,401 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useModal } from '../composables/Core/useModal'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
usePageTitle('Add Transaction')
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const isSubmitting = ref(false)
|
||||
const stores = ref([])
|
||||
const products = ref([])
|
||||
const transactionTypes = ref([])
|
||||
const errors = ref({})
|
||||
const showSuccessAnimation = ref(false)
|
||||
const showSuccessState = ref(false)
|
||||
|
||||
|
||||
const form = ref({
|
||||
scope: 'global', // 'global' or 'store'
|
||||
store_hash: '',
|
||||
product_hash: '',
|
||||
type: '',
|
||||
amount: '',
|
||||
description: '',
|
||||
status: 'completed'
|
||||
})
|
||||
|
||||
// Initialize
|
||||
onMounted(async () => {
|
||||
fetchInitialData()
|
||||
})
|
||||
|
||||
const fetchInitialData = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
// Fetch Stores
|
||||
const storesResponse = await axios.post('/ListStores/MyStores/data')
|
||||
if (storesResponse.data && Array.isArray(storesResponse.data)) {
|
||||
stores.value = storesResponse.data
|
||||
}
|
||||
|
||||
// Fetch Transaction Types
|
||||
const typesResponse = await axios.post('/admin/transactions/types')
|
||||
if (typesResponse.data && Array.isArray(typesResponse.data)) {
|
||||
transactionTypes.value = typesResponse.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(() => form.value.store_hash, async (newStoreHash) => {
|
||||
if (newStoreHash) {
|
||||
fetchStoreProducts(newStoreHash)
|
||||
} else {
|
||||
products.value = []
|
||||
form.value.product_hash = ''
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => form.value.scope, (newScope) => {
|
||||
if (newScope === 'global') {
|
||||
form.value.store_hash = ''
|
||||
form.value.product_hash = ''
|
||||
}
|
||||
})
|
||||
|
||||
const fetchStoreProducts = async (storeHash) => {
|
||||
try {
|
||||
const response = await axios.post('/View/Store/Details/data', { target: storeHash })
|
||||
if (response.data && response.data.products) {
|
||||
products.value = response.data.products
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Form Submission
|
||||
const submitForm = async () => {
|
||||
isSubmitting.value = true
|
||||
errors.value = {}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
amount: form.value.amount,
|
||||
type: form.value.type,
|
||||
description: form.value.description,
|
||||
status: form.value.status
|
||||
}
|
||||
|
||||
if (form.value.scope === 'store') {
|
||||
payload.store_hash = form.value.store_hash
|
||||
if (form.value.product_hash) {
|
||||
payload.product_hash = form.value.product_hash
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.post('/admin/transactions/create', payload)
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
// Success!
|
||||
showSuccessState.value = true
|
||||
showSuccessAnimation.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
showSuccessAnimation.value = false
|
||||
navigate({ page: 'ManageGlobalTransactions' })
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response && error.response.data && error.response.data.errors) {
|
||||
errors.value = error.response.data.errors
|
||||
} else {
|
||||
console.error('Error submitting transaction:', error)
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to save transaction. Please try again.'
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="add-transaction-page pb-5">
|
||||
<br><br>
|
||||
|
||||
<div class="tf-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<BackButton />
|
||||
<h4 class="fw_6 mb-0">Record New Transaction</h4>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-lg border-0 overflow-hidden glass-card">
|
||||
<div class="card-header bg-gradient-primary text-white p-4">
|
||||
<h5 class="mb-0"><i class="fas fa-plus-circle me-2"></i> Transaction Details</h5>
|
||||
<p class="mb-0 text-white-50 small">Enter the transaction details below to record it in the system.</p>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
<form @submit.prevent="submitForm">
|
||||
<!-- Scope Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw_6 d-block mb-3">Transaction Scope</label>
|
||||
<div class="scope-toggle-container shadow-sm rounded-pill overflow-hidden p-1">
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="scope"
|
||||
id="scope-global"
|
||||
value="global"
|
||||
v-model="form.scope"
|
||||
>
|
||||
<label class="btn btn-outline-primary border-0 rounded-pill py-2" for="scope-global">
|
||||
<i class="fas fa-globe me-1"></i> Global
|
||||
</label>
|
||||
|
||||
<input
|
||||
type="radio"
|
||||
class="btn-check"
|
||||
name="scope"
|
||||
id="scope-store"
|
||||
value="store"
|
||||
v-model="form.scope"
|
||||
>
|
||||
<label class="btn btn-outline-primary border-0 rounded-pill py-2" for="scope-store">
|
||||
<i class="fas fa-store me-1"></i> Store Specific
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Store Selection (Conditional) -->
|
||||
<div v-if="form.scope === 'store'" class="col-md-6 mb-3">
|
||||
<label class="form-label fw_6">Select Store</label>
|
||||
<select
|
||||
v-model="form.store_hash"
|
||||
class="form-select form-select-lg highlight-focus"
|
||||
:class="{ 'is-invalid': errors.store_hash }"
|
||||
required
|
||||
>
|
||||
<option value="">-- Choose a Store --</option>
|
||||
<option v-for="store in stores" :key="store.hashkey" :value="store.hashkey">
|
||||
{{ store.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="errors.store_hash" class="invalid-feedback">{{ errors.store_hash[0] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Selection (Conditional & Optional) -->
|
||||
<div v-if="form.scope === 'store'" class="col-md-6 mb-3">
|
||||
<label class="form-label fw_6">Related Product (Optional)</label>
|
||||
<select
|
||||
v-model="form.product_hash"
|
||||
class="form-select form-select-lg highlight-focus"
|
||||
:disabled="!form.store_hash"
|
||||
>
|
||||
<option value="">-- No Specific Product --</option>
|
||||
<option v-for="product in products" :key="product.hashkey" :value="product.hashkey">
|
||||
{{ product.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Transaction Type -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw_6">Transaction Type</label>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="form-select form-select-lg highlight-focus"
|
||||
:class="{ 'is-invalid': errors.type }"
|
||||
required
|
||||
>
|
||||
<option value="">-- Choose Type --</option>
|
||||
<option v-for="type in transactionTypes" :key="type.value" :value="type.value">
|
||||
{{ type.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="errors.type" class="invalid-feedback">{{ errors.type[0] }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw_6">Amount (PHP)</label>
|
||||
<div class="input-group input-group-lg custom-input-group">
|
||||
<span class="input-group-text">₱</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
v-model="form.amount"
|
||||
class="form-control highlight-focus"
|
||||
:class="{ 'is-invalid': errors.amount }"
|
||||
placeholder="0.00"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
<div v-if="errors.amount" class="text-danger small mt-1">{{ errors.amount[0] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw_6">Description / Notes</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-control highlight-focus"
|
||||
rows="3"
|
||||
placeholder="Details about this transaction..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<AnimatedButton
|
||||
type="submit"
|
||||
btnClass="btn btn-primary btn-lg w-100 glow-button"
|
||||
:loading="isSubmitting"
|
||||
:success="showSuccessState"
|
||||
>
|
||||
<i class="fas fa-save me-2"></i> Record Transaction
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSuccessAnimation" class="success-overlay">
|
||||
<div class="text-center animate-bounce-in">
|
||||
<LottiePlayer
|
||||
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
|
||||
:loop="false"
|
||||
width="200px"
|
||||
height="200px"
|
||||
/>
|
||||
<h3 class="fw_8 mt-3 text-primary headline-gradient">Success!</h3>
|
||||
<p class="text-muted">Transaction recorded in the ledger.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .glass-card {
|
||||
background: rgba(31, 34, 40, 0.7);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bg-gradient-primary {
|
||||
background: linear-gradient(135deg, #4e73df 0%, #224abe 100%);
|
||||
}
|
||||
|
||||
.highlight-focus:focus {
|
||||
border-color: var(--accent-color, #4e73df);
|
||||
box-shadow: 0 0 0 0.25rem var(--accent-soft, rgba(78, 115, 223, 0.25));
|
||||
}
|
||||
|
||||
.form-select-lg, .form-control-lg {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.glow-button {
|
||||
border-radius: 15px;
|
||||
padding: 15px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.glow-button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(78, 115, 223, 0.4);
|
||||
}
|
||||
|
||||
.btn-check:checked + .btn-outline-primary {
|
||||
background-color: var(--accent-color, #4e73df);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.scope-toggle-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
.scope-toggle-container .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-input-group .input-group-text {
|
||||
background-color: var(--bg-tertiary, #f0f2f5);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tf-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .success-overlay {
|
||||
background: rgba(18, 20, 24, 0.95);
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
70% { transform: scale(0.9); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.headline-gradient {
|
||||
background: linear-gradient(135deg, #4e73df 0%, #224abe 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
194
resources/js/Pages/AdminConsole.vue
Normal file
194
resources/js/Pages/AdminConsole.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Admin Console');
|
||||
|
||||
const stats = ref({});
|
||||
const logs = ref([]);
|
||||
const queryResult = ref(null);
|
||||
const queryError = ref('');
|
||||
const activeTab = ref('stats');
|
||||
const loading = ref(false);
|
||||
|
||||
const sqlQuery = ref('');
|
||||
const maintenanceMode = ref(false);
|
||||
const globalMessage = ref('');
|
||||
|
||||
const tabs = [
|
||||
{ key: 'stats', label: 'Statistics' },
|
||||
{ key: 'logs', label: 'Activity Logs' },
|
||||
{ key: 'query', label: 'SQL Query' },
|
||||
{ key: 'maintenance', label: 'Maintenance' },
|
||||
];
|
||||
|
||||
const loadStats = async () => {
|
||||
const res = await executeRequest('/admin/console/stats');
|
||||
if (res.success) stats.value = res.data;
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
loading.value = true;
|
||||
const res = await executeRequest('/admin/console/logs');
|
||||
if (res.success) logs.value = res.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const runQuery = async () => {
|
||||
if (!sqlQuery.value.trim()) return;
|
||||
queryError.value = '';
|
||||
queryResult.value = null;
|
||||
const res = await executeRequest('/admin/console/query', 'POST', { query: sqlQuery.value });
|
||||
if (res.success) {
|
||||
queryResult.value = res.data;
|
||||
} else {
|
||||
queryError.value = res.message ?? 'Query failed.';
|
||||
}
|
||||
};
|
||||
|
||||
const clearCache = async () => {
|
||||
if (!confirm('Clear all application cache?')) return;
|
||||
const res = await executeRequest('/admin/console/cache/clear', 'POST');
|
||||
alert(res.message ?? 'Done.');
|
||||
};
|
||||
|
||||
const setMaintenance = async (val) => {
|
||||
await executeRequest('/admin/console/maintenance', 'POST', { enabled: val });
|
||||
maintenanceMode.value = val;
|
||||
};
|
||||
|
||||
const setGlobalMessage = async () => {
|
||||
const res = await executeRequest('/admin/console/message', 'POST', { message: globalMessage.value });
|
||||
if (res.success) alert('Global message updated.');
|
||||
};
|
||||
|
||||
const queryColumns = () => {
|
||||
if (!queryResult.value || !queryResult.value.length) return [];
|
||||
return Object.keys(queryResult.value[0]);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
const onTabChange = (tab) => {
|
||||
activeTab.value = tab;
|
||||
if (tab === 'logs') loadLogs();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-1">Admin Console</h1>
|
||||
<p class="text-gray-500 text-sm mb-4">System administration and diagnostics</p>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b mb-5">
|
||||
<button
|
||||
v-for="tab in tabs" :key="tab.key"
|
||||
@click="onTabChange(tab.key)"
|
||||
:class="`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition ${activeTab === tab.key ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}`">
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div v-if="activeTab === 'stats'">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-6">
|
||||
<div v-for="(val, key) in stats" :key="key" class="bg-white rounded-lg shadow p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ val }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1 capitalize">{{ key.replace(/_/g, ' ') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="loadStats" class="btn-secondary text-sm">Refresh</button>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div v-else-if="activeTab === 'logs'">
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600 text-left">
|
||||
<tr>
|
||||
<th class="px-4 py-2">Time</th>
|
||||
<th class="px-4 py-2">User</th>
|
||||
<th class="px-4 py-2">Action</th>
|
||||
<th class="px-4 py-2">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in logs" :key="log.id" class="border-t hover:bg-gray-50">
|
||||
<td class="px-4 py-2 text-gray-400 whitespace-nowrap">{{ log.created_at }}</td>
|
||||
<td class="px-4 py-2">{{ log.user?.username ?? log.user_id }}</td>
|
||||
<td class="px-4 py-2 font-mono text-xs">{{ log.action }}</td>
|
||||
<td class="px-4 py-2 text-gray-500 max-w-xs truncate">{{ log.details }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!logs.length" class="text-center text-gray-400 py-6">No logs found.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SQL Query -->
|
||||
<div v-else-if="activeTab === 'query'">
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4 text-sm text-yellow-800">
|
||||
Only SELECT queries are permitted. This console is for diagnostics only.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="sqlQuery"
|
||||
class="input font-mono text-sm h-32 mb-3"
|
||||
placeholder="SELECT * FROM barangay_residents LIMIT 10;"
|
||||
></textarea>
|
||||
<button @click="runQuery" class="btn-primary mb-4">Run Query</button>
|
||||
|
||||
<div v-if="queryError" class="bg-red-50 border border-red-200 rounded p-3 text-red-700 text-sm mb-3">
|
||||
{{ queryError }}
|
||||
</div>
|
||||
|
||||
<div v-if="queryResult" class="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<div class="text-xs text-gray-400 px-4 pt-3">{{ queryResult.length }} row(s)</div>
|
||||
<table class="w-full text-xs">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th v-for="col in queryColumns()" :key="col" class="px-3 py-2 text-left text-gray-600 font-medium">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, i) in queryResult" :key="i" class="border-t">
|
||||
<td v-for="col in queryColumns()" :key="col" class="px-3 py-1.5 text-gray-700 max-w-xs truncate">
|
||||
{{ row[col] ?? '—' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!queryResult.length" class="text-center text-gray-400 py-4 text-sm">No rows returned.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<div v-else-if="activeTab === 'maintenance'" class="space-y-5">
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">Maintenance Mode</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">Enabling maintenance mode will show a maintenance page to non-admin users.</p>
|
||||
<div class="flex gap-2">
|
||||
<button @click="setMaintenance(true)" class="btn-sm bg-red-500 text-white">Enable Maintenance</button>
|
||||
<button @click="setMaintenance(false)" class="btn-sm bg-green-500 text-white">Disable Maintenance</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">Global Message</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">Show a banner message to all users across the system.</p>
|
||||
<textarea v-model="globalMessage" class="input h-20 mb-3" placeholder="System maintenance scheduled for..."></textarea>
|
||||
<button @click="setGlobalMessage" class="btn-secondary">Set Global Message</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-5">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">Cache Management</h3>
|
||||
<p class="text-sm text-gray-500 mb-3">Clear Redis cache and application cached data.</p>
|
||||
<button @click="clearCache" class="btn-sm bg-orange-500 text-white">Clear All Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useChapters } from '../composables/useChapters.js';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import { useModal } from '../composables/Core/useModal.js';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
|
||||
usePageTitle('Assign Officer');
|
||||
|
||||
const { fetchOfficerScope, assignOfficer, loading } = useChapters();
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const ROLES = ['PRESIDENT', 'VICE_PRESIDENT', 'SECRETARY', 'TREASURER', 'AUDITOR', 'BOARD_MEMBER'];
|
||||
const roleLabel = (r) => ({
|
||||
PRESIDENT: 'President', VICE_PRESIDENT: 'Vice President', SECRETARY: 'Secretary',
|
||||
TREASURER: 'Treasurer', AUDITOR: 'Auditor', BOARD_MEMBER: 'Board Member',
|
||||
}[r] || r);
|
||||
|
||||
const ownChapter = ref(null);
|
||||
const eligibleMembers = ref([]);
|
||||
const childChapters = ref([]);
|
||||
|
||||
const memberFilter = ref('');
|
||||
const selectedMember = ref(null);
|
||||
const selectedChapter = ref(null);
|
||||
const selectedRole = ref('');
|
||||
const submitting = ref(false);
|
||||
|
||||
const step = computed(() => {
|
||||
if (!selectedMember.value) return 1;
|
||||
if (!selectedChapter.value) return 2;
|
||||
if (!selectedRole.value) return 3;
|
||||
return 4;
|
||||
});
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
const q = memberFilter.value.trim().toLowerCase();
|
||||
if (!q) return eligibleMembers.value;
|
||||
return eligibleMembers.value.filter((m) => (m.name || '').toLowerCase().includes(q));
|
||||
});
|
||||
|
||||
const selectMember = (m) => { selectedMember.value = m; };
|
||||
const selectChapter = (c) => { selectedChapter.value = c; };
|
||||
|
||||
const back = () => {
|
||||
if (selectedRole.value) { selectedRole.value = ''; return; }
|
||||
if (selectedChapter.value) { selectedChapter.value = null; return; }
|
||||
if (selectedMember.value) { selectedMember.value = null; return; }
|
||||
};
|
||||
|
||||
const confirmAssign = async () => {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await assignOfficer({
|
||||
memberUserHashkey: selectedMember.value.user_hashkey,
|
||||
childChapterId: selectedChapter.value.id,
|
||||
role: selectedRole.value,
|
||||
});
|
||||
if (res.success) {
|
||||
modal.quickDismiss({
|
||||
title: 'Officer Assigned',
|
||||
body: res.message || 'Member assigned successfully.',
|
||||
});
|
||||
navigate({ page: 'Home' });
|
||||
}
|
||||
} catch (err) {
|
||||
modal.quickDismiss({
|
||||
title: 'Error',
|
||||
body: err.response?.data?.message || err.response?.data?.error || 'Failed to assign officer.',
|
||||
});
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const scope = await fetchOfficerScope();
|
||||
ownChapter.value = scope?.own_chapter ?? null;
|
||||
eligibleMembers.value = scope?.eligible_members ?? [];
|
||||
childChapters.value = scope?.child_chapters ?? [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container py-4" style="max-width: 620px;">
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<button v-if="step > 1" class="btn btn-sm btn-outline-secondary rounded-circle" @click="back">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
</button>
|
||||
<h5 class="fw-bold mb-0"><i class="fas fa-user-tie me-2"></i>Assign Officer</h5>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !ownChapter" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!ownChapter" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-warning mb-2"></i>
|
||||
<p>You are not assigned to a chapter.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Step 1: pick member -->
|
||||
<div v-if="step === 1" class="panel rounded-4 p-3">
|
||||
<h6 class="fw-semibold mb-2">1. Select a member</h6>
|
||||
<input v-model="memberFilter" type="text" class="form-control rounded-pill mb-3" placeholder="Search members..." />
|
||||
<div v-if="!filteredMembers.length" class="text-muted small py-3 text-center">
|
||||
No eligible members in {{ ownChapter.name }}.
|
||||
</div>
|
||||
<div v-for="m in filteredMembers" :key="m.user_hashkey" class="row-item rounded-3 p-3 mb-2" role="button" @click="selectMember(m)">
|
||||
<i class="fas fa-user me-2 text-muted"></i><span class="fw-semibold">{{ m.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: pick child chapter -->
|
||||
<div v-else-if="step === 2" class="panel rounded-4 p-3">
|
||||
<h6 class="fw-semibold mb-2">2. Select a sub-chapter</h6>
|
||||
<p class="small text-muted">Assigning <strong>{{ selectedMember.name }}</strong></p>
|
||||
<div v-if="!childChapters.length" class="text-muted small py-3 text-center">
|
||||
No sub-chapters available. Create one first.
|
||||
</div>
|
||||
<div v-for="c in childChapters" :key="c.id" class="row-item rounded-3 p-3 mb-2" role="button" @click="selectChapter(c)">
|
||||
<span class="badge rounded-pill level-badge me-2">{{ (c.level || '').toUpperCase() }}</span>
|
||||
<span class="fw-semibold">{{ c.name }}</span>
|
||||
<span class="small text-muted ms-2">{{ c.active_members_count }} members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: pick role -->
|
||||
<div v-else-if="step === 3" class="panel rounded-4 p-3">
|
||||
<h6 class="fw-semibold mb-3">3. Select a role</h6>
|
||||
<div class="d-grid gap-2">
|
||||
<button v-for="r in ROLES" :key="r" class="row-item rounded-3 p-3 text-start" @click="selectedRole = r">
|
||||
<i class="fas fa-id-badge me-2 text-muted"></i>{{ roleLabel(r) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: confirm -->
|
||||
<div v-else class="panel rounded-4 p-4">
|
||||
<h6 class="fw-semibold mb-3">4. Confirm</h6>
|
||||
<p>
|
||||
Assign <strong>{{ selectedMember.name }}</strong> as
|
||||
<strong>{{ roleLabel(selectedRole) }}</strong> to
|
||||
<strong>{{ selectedChapter.name }}</strong>?
|
||||
</p>
|
||||
<div class="alert alert-warning rounded-3 small py-2">
|
||||
This will MOVE them from {{ ownChapter.name }} to {{ selectedChapter.name }}.
|
||||
</div>
|
||||
<button class="btn btn-primary rounded-pill w-100 py-2 fw-semibold" :disabled="submitting" @click="confirmAssign">
|
||||
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i v-else class="fas fa-check me-2"></i>
|
||||
{{ submitting ? 'Assigning...' : 'Confirm Assignment' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.row-item {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
cursor: pointer;
|
||||
}
|
||||
.level-badge {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
:global(.dark-mode) .panel,
|
||||
:global(.dark-mode) .row-item {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,922 +0,0 @@
|
||||
<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>
|
||||
152
resources/js/Pages/Barangay/BlotterDetail.vue
Normal file
152
resources/js/Pages/Barangay/BlotterDetail.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Blotter Detail');
|
||||
|
||||
const blotter = ref(null);
|
||||
const hearings = ref([]);
|
||||
const loading = ref(false);
|
||||
const showHearingModal = ref(false);
|
||||
const hearingForm = ref({ hearing_date: '', officer_id: '', notes: '' });
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const target = urlParams.get('target');
|
||||
|
||||
const statusLabels = {
|
||||
FILED: 'Filed', FOR_HEARING: 'For Hearing', SETTLED: 'Settled',
|
||||
RESOLVED: 'Resolved', DISMISSED: 'Dismissed', ENDORSED: 'Endorsed',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
FILED: 'bg-blue-100 text-blue-700', FOR_HEARING: 'bg-yellow-100 text-yellow-700',
|
||||
SETTLED: 'bg-green-100 text-green-700', RESOLVED: 'bg-teal-100 text-teal-700',
|
||||
DISMISSED: 'bg-gray-100 text-gray-700', ENDORSED: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
const [bRes, hRes] = await Promise.all([
|
||||
executeRequest('/blotters/show', 'POST', { target }),
|
||||
executeRequest('/blotters/hearings', 'POST', { blotter: target }),
|
||||
]);
|
||||
if (bRes.success) blotter.value = bRes.data;
|
||||
if (hRes.success) hearings.value = hRes.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const updateStatus = async (status) => {
|
||||
if (!confirm(`Change status to ${statusLabels[status]}?`)) return;
|
||||
await executeRequest('/blotters/status', 'POST', { target, status });
|
||||
loadData();
|
||||
};
|
||||
|
||||
const scheduleHearing = async () => {
|
||||
await executeRequest('/blotters/hearings/schedule', 'POST', { blotter: target, ...hearingForm.value });
|
||||
showHearingModal.value = false;
|
||||
hearingForm.value = { hearing_date: '', officer_id: '', notes: '' };
|
||||
loadData();
|
||||
};
|
||||
|
||||
const getStatus = () => blotter.value?.status?.value ?? blotter.value?.status;
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto">
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else-if="blotter">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Blotter {{ blotter.blotter_no }}</h1>
|
||||
<p class="text-gray-500 text-sm">Filed: {{ blotter.complaint_date }}</p>
|
||||
</div>
|
||||
<span :class="`px-3 py-1 rounded-full text-sm font-medium ${statusColors[getStatus()] ?? 'bg-gray-100'}`">
|
||||
{{ statusLabels[getStatus()] ?? getStatus() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-5">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-2 border-b pb-1">Complainant</h3>
|
||||
<p class="font-medium">{{ blotter.complainant_name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ blotter.complainant_contact }}</p>
|
||||
<p class="text-sm text-gray-500">{{ blotter.complainant_address }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-2 border-b pb-1">Respondent</h3>
|
||||
<p class="font-medium">{{ blotter.respondent_name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ blotter.respondent_contact }}</p>
|
||||
<p class="text-sm text-gray-500">{{ blotter.respondent_address }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-2">Incident Details</h3>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-3">
|
||||
<div><span class="text-gray-500">Type:</span> {{ blotter.incident_type }}</div>
|
||||
<div><span class="text-gray-500">Date:</span> {{ blotter.incident_date }}</div>
|
||||
<div class="col-span-2"><span class="text-gray-500">Location:</span> {{ blotter.incident_location }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 rounded p-3 text-sm text-gray-700">{{ blotter.narrative }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="blotter.resolution" class="bg-green-50 rounded-lg p-4 mb-4">
|
||||
<h3 class="font-semibold text-green-700 mb-1">Resolution</h3>
|
||||
<p class="text-sm">{{ blotter.resolution }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Hearings -->
|
||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-gray-700">Hearings ({{ hearings.length }})</h3>
|
||||
<button @click="showHearingModal = true" class="btn-sm btn-primary">Schedule Hearing</button>
|
||||
</div>
|
||||
<div v-for="h in hearings" :key="h.id" class="border-b py-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="font-medium">{{ h.hearing_date }}</span>
|
||||
<span :class="`text-xs px-2 py-0.5 rounded ${h.status === 'HELD' ? 'bg-green-100 text-green-700' : h.status === 'POSTPONED' ? 'bg-orange-100 text-orange-700' : 'bg-blue-100 text-blue-700'}`">{{ h.status }}</span>
|
||||
</div>
|
||||
<p v-if="h.notes" class="text-gray-500 mt-1">{{ h.notes }}</p>
|
||||
</div>
|
||||
<p v-if="!hearings.length" class="text-gray-400 text-sm">No hearings scheduled.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold text-gray-700 mb-3">Update Status</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="getStatus() === 'FILED'" @click="updateStatus('FOR_HEARING')" class="btn-sm bg-yellow-500 text-white">Set For Hearing</button>
|
||||
<button v-if="['FILED','FOR_HEARING'].includes(getStatus())" @click="updateStatus('SETTLED')" class="btn-sm bg-green-500 text-white">Mark Settled</button>
|
||||
<button v-if="['FILED','FOR_HEARING','SETTLED'].includes(getStatus())" @click="updateStatus('RESOLVED')" class="btn-sm bg-teal-500 text-white">Mark Resolved</button>
|
||||
<button v-if="['FILED','FOR_HEARING'].includes(getStatus())" @click="updateStatus('DISMISSED')" class="btn-sm bg-gray-500 text-white">Dismiss</button>
|
||||
<button v-if="['FILED','FOR_HEARING'].includes(getStatus())" @click="updateStatus('ENDORSED')" class="btn-sm bg-purple-500 text-white">Endorse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule Hearing Modal -->
|
||||
<div v-if="showHearingModal" class="modal-overlay" @click.self="showHearingModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-4">Schedule a Hearing</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="label">Hearing Date & Time *</label>
|
||||
<input v-model="hearingForm.hearing_date" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Notes</label>
|
||||
<textarea v-model="hearingForm.notes" class="input h-20"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showHearingModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="scheduleHearing" class="btn-primary">Schedule</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-center text-gray-400 py-8">Blotter not found.</p>
|
||||
</div>
|
||||
</template>
|
||||
178
resources/js/Pages/Barangay/BudgetLedger.vue
Normal file
178
resources/js/Pages/Barangay/BudgetLedger.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Budget & Finance');
|
||||
|
||||
const entries = ref([]);
|
||||
const summary = ref(null);
|
||||
const fiscalYears = ref([]);
|
||||
const loading = ref(false);
|
||||
const year = ref(new Date().getFullYear());
|
||||
const categoryFilter = ref('');
|
||||
const showModal = ref(false);
|
||||
const editingItem = ref(null);
|
||||
|
||||
const blankForm = () => ({
|
||||
fiscal_year: year.value, category: 'INCOME', source: '',
|
||||
amount: '', description: '', date: '', reference: '',
|
||||
});
|
||||
const form = ref(blankForm());
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams({ year: year.value });
|
||||
if (categoryFilter.value) params.append('category', categoryFilter.value);
|
||||
const [entriesRes, sumRes, yearsRes] = await Promise.all([
|
||||
executeRequest(`/budget?${params}`),
|
||||
executeRequest(`/budget/summary?year=${year.value}`),
|
||||
executeRequest('/budget/fiscal-years'),
|
||||
]);
|
||||
if (entriesRes.success) entries.value = entriesRes.data.data ?? entriesRes.data;
|
||||
if (sumRes.success) summary.value = sumRes.data;
|
||||
if (yearsRes.success) fiscalYears.value = yearsRes.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openCreate = () => { editingItem.value = null; form.value = blankForm(); showModal.value = true; };
|
||||
const openEdit = (item) => { editingItem.value = item; form.value = { ...item }; showModal.value = true; };
|
||||
|
||||
const saveEntry = async () => {
|
||||
const url = editingItem.value ? '/budget/update' : '/budget/create';
|
||||
const payload = editingItem.value ? { target: editingItem.value.hashkey, ...form.value } : form.value;
|
||||
const res = await executeRequest(url, 'POST', payload);
|
||||
if (res.success) { showModal.value = false; loadData(); }
|
||||
};
|
||||
|
||||
const deleteEntry = async (item) => {
|
||||
if (!confirm('Delete this entry?')) return;
|
||||
await executeRequest('/budget/delete', 'POST', { target: item.hashkey });
|
||||
loadData();
|
||||
};
|
||||
|
||||
const incomeTotal = computed(() => summary.value?.income ?? 0);
|
||||
const expenseTotal = computed(() => summary.value?.expense ?? 0);
|
||||
const balance = computed(() => summary.value?.balance ?? 0);
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Budget & Finance</h1>
|
||||
<button @click="openCreate" class="btn-primary">+ Add Entry</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="grid grid-cols-3 gap-3 mb-5">
|
||||
<div class="bg-green-50 rounded-lg shadow p-4 text-center">
|
||||
<div class="text-xs text-gray-500 mb-1">Total Income</div>
|
||||
<div class="text-2xl font-bold text-green-600">₱{{ Number(incomeTotal).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div class="bg-red-50 rounded-lg shadow p-4 text-center">
|
||||
<div class="text-xs text-gray-500 mb-1">Total Expense</div>
|
||||
<div class="text-2xl font-bold text-red-600">₱{{ Number(expenseTotal).toLocaleString() }}</div>
|
||||
</div>
|
||||
<div :class="`rounded-lg shadow p-4 text-center ${balance >= 0 ? 'bg-blue-50' : 'bg-orange-50'}`">
|
||||
<div class="text-xs text-gray-500 mb-1">Net Balance</div>
|
||||
<div :class="`text-2xl font-bold ${balance >= 0 ? 'text-blue-600' : 'text-orange-600'}`">
|
||||
{{ balance >= 0 ? '' : '-' }}₱{{ Math.abs(Number(balance)).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<select v-model="year" @change="loadData" class="input w-28">
|
||||
<option v-for="y in fiscalYears" :key="y" :value="y">{{ y }}</option>
|
||||
<option :value="new Date().getFullYear()">{{ new Date().getFullYear() }}</option>
|
||||
</select>
|
||||
<select v-model="categoryFilter" @change="loadData" class="input w-32">
|
||||
<option value="">All</option>
|
||||
<option value="INCOME">Income</option>
|
||||
<option value="EXPENSE">Expense</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left p-2">Date</th>
|
||||
<th class="text-left p-2">Category</th>
|
||||
<th class="text-left p-2">Source</th>
|
||||
<th class="text-left p-2">Description</th>
|
||||
<th class="text-right p-2">Amount</th>
|
||||
<th class="text-left p-2">Reference</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="e in entries" :key="e.id" class="border-b hover:bg-gray-50">
|
||||
<td class="p-2 text-xs">{{ e.date }}</td>
|
||||
<td class="p-2">
|
||||
<span :class="`px-2 py-0.5 rounded text-xs font-medium ${e.category === 'INCOME' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`">
|
||||
{{ e.category }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2">{{ e.source }}</td>
|
||||
<td class="p-2 text-gray-600">{{ e.description }}</td>
|
||||
<td class="p-2 text-right font-medium">₱{{ Number(e.amount).toLocaleString() }}</td>
|
||||
<td class="p-2 text-xs text-gray-400">{{ e.reference || '—' }}</td>
|
||||
<td class="p-2 flex gap-1">
|
||||
<button @click="openEdit(e)" class="btn-sm">Edit</button>
|
||||
<button @click="deleteEntry(e)" class="btn-sm text-red-500">Del</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!entries.length" class="text-center text-gray-400 py-8">No entries for this period.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingItem ? 'Edit Entry' : 'New Budget Entry' }}</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Fiscal Year *</label>
|
||||
<input v-model="form.fiscal_year" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Category *</label>
|
||||
<select v-model="form.category" class="input">
|
||||
<option value="INCOME">Income</option>
|
||||
<option value="EXPENSE">Expense</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Source *</label>
|
||||
<input v-model="form.source" class="input" placeholder="e.g. IRA, Barangay Tax" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Amount (₱) *</label>
|
||||
<input v-model="form.amount" type="number" step="0.01" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Date *</label>
|
||||
<input v-model="form.date" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Reference / OR No.</label>
|
||||
<input v-model="form.reference" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Description</label>
|
||||
<input v-model="form.description" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveEntry" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
188
resources/js/Pages/Barangay/DocumentRequestDetail.vue
Normal file
188
resources/js/Pages/Barangay/DocumentRequestDetail.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
import { useAuth } from '../../composables/Core/useAuth.js';
|
||||
|
||||
usePageTitle('Document Request Detail');
|
||||
|
||||
const { isBarangayStaff } = useAuth();
|
||||
|
||||
const request = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const target = urlParams.get('target');
|
||||
|
||||
const statusColors = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-700',
|
||||
PAYMENT_PENDING: 'bg-orange-100 text-orange-700',
|
||||
PAID: 'bg-blue-100 text-blue-700',
|
||||
PROCESSING:'bg-indigo-100 text-indigo-700',
|
||||
READY: 'bg-teal-100 text-teal-700',
|
||||
CLAIMED: 'bg-green-100 text-green-700',
|
||||
CANCELLED: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
PENDING: 'Pending',
|
||||
PAYMENT_PENDING: 'Awaiting Payment',
|
||||
PAID: 'Paid',
|
||||
PROCESSING:'Processing',
|
||||
READY: 'Ready for Pickup',
|
||||
CLAIMED: 'Claimed',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const getStatus = computed(() => request.value?.status?.value ?? request.value?.status ?? '');
|
||||
|
||||
const loadRequest = async () => {
|
||||
loading.value = true;
|
||||
const res = await executeRequest('/admin/documents/show', 'POST', { target });
|
||||
if (res.success) request.value = res.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const confirmPayment = async () => {
|
||||
if (!confirm('Confirm payment received?')) return;
|
||||
const res = await executeRequest('/admin/documents/confirm-payment', 'POST', {
|
||||
target,
|
||||
amount_paid: request.value?.base_fee ?? 0,
|
||||
payment_method: 'CASH',
|
||||
});
|
||||
if (res.success) loadRequest();
|
||||
};
|
||||
|
||||
const markReady = async () => {
|
||||
if (!confirm('Mark as ready for pickup?')) return;
|
||||
const res = await executeRequest('/admin/documents/mark-ready', 'POST', { target });
|
||||
if (res.success) loadRequest();
|
||||
};
|
||||
|
||||
const markClaimed = async () => {
|
||||
if (!confirm('Mark as claimed by resident?')) return;
|
||||
const res = await executeRequest('/admin/documents/mark-claimed', 'POST', { target });
|
||||
if (res.success) loadRequest();
|
||||
};
|
||||
|
||||
const cancel = async () => {
|
||||
if (!confirm('Cancel this request?')) return;
|
||||
const res = await executeRequest('/documents/cancel', 'POST', { target });
|
||||
if (res.success) loadRequest();
|
||||
};
|
||||
|
||||
onMounted(loadRequest);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<button @click="navigate(isBarangayStaff ? '/barangay/managedocumentrequests' : '/barangay/requestdocument')"
|
||||
class="text-sm text-blue-500 mb-4 inline-flex items-center gap-1">
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
|
||||
<div v-else-if="request">
|
||||
<!-- Header card -->
|
||||
<div class="bg-white rounded-xl shadow p-5 mb-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono font-semibold text-blue-600 text-lg">{{ request.request_no }}</span>
|
||||
<span :class="`text-xs px-2 py-0.5 rounded-full font-medium ${statusColors[getStatus] ?? 'bg-gray-100'}`">
|
||||
{{ statusLabels[getStatus] ?? getStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xl font-bold text-gray-800">{{ request.document_type?.name ?? request.request_type }}</p>
|
||||
<p class="text-sm text-gray-500 mt-1">Submitted: {{ request.created_at }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
{{ request.base_fee > 0 ? `₱${Number(request.base_fee).toFixed(2)}` : 'Free' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">Fee</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requester info -->
|
||||
<div class="bg-white rounded-xl shadow p-5 mb-4">
|
||||
<h2 class="font-semibold text-gray-700 mb-3 border-b pb-2">Requester Information</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">Name:</span> {{ request.resident?.fullname ?? request.requester_name ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Purok:</span> {{ request.resident?.purok ?? '—' }}</div>
|
||||
<div class="col-span-2"><span class="text-gray-400">Purpose:</span> {{ request.purpose ?? '—' }}</div>
|
||||
<div v-if="request.remarks" class="col-span-2">
|
||||
<span class="text-gray-400">Remarks:</span> {{ request.remarks }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment history -->
|
||||
<div v-if="request.payments && request.payments.length" class="bg-white rounded-xl shadow p-5 mb-4">
|
||||
<h2 class="font-semibold text-gray-700 mb-3 border-b pb-2">Payment Records</h2>
|
||||
<div v-for="p in request.payments" :key="p.id" class="flex justify-between text-sm py-1 border-b last:border-0">
|
||||
<span>{{ p.payment_method }} — {{ p.created_at }}</span>
|
||||
<span class="font-semibold text-green-600">₱{{ Number(p.amount_paid).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Staff actions -->
|
||||
<div v-if="isBarangayStaff" class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3">Actions</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="['PENDING','PAYMENT_PENDING'].includes(getStatus)"
|
||||
@click="confirmPayment"
|
||||
class="btn-sm bg-green-500 text-white">
|
||||
Confirm Payment
|
||||
</button>
|
||||
<button
|
||||
v-if="['PAID','PROCESSING'].includes(getStatus)"
|
||||
@click="markReady"
|
||||
class="btn-sm bg-teal-500 text-white">
|
||||
Mark Ready for Pickup
|
||||
</button>
|
||||
<button
|
||||
v-if="getStatus === 'READY'"
|
||||
@click="markClaimed"
|
||||
class="btn-sm bg-blue-500 text-white">
|
||||
Mark Claimed
|
||||
</button>
|
||||
<button
|
||||
v-if="!['CLAIMED','CANCELLED'].includes(getStatus)"
|
||||
@click="cancel"
|
||||
class="btn-sm bg-red-100 text-red-600">
|
||||
Cancel Request
|
||||
</button>
|
||||
<p v-if="['CLAIMED','CANCELLED'].includes(getStatus)" class="text-sm text-gray-400">
|
||||
This request is {{ statusLabels[getStatus].toLowerCase() }}. No further actions available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resident view: status tracker -->
|
||||
<div v-else class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3">Request Progress</h2>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<template v-for="(step, i) in ['PENDING','PAID','PROCESSING','READY','CLAIMED']" :key="step">
|
||||
<div :class="`px-2 py-1 rounded font-medium ${['PENDING','PAYMENT_PENDING','PAID','PROCESSING','READY','CLAIMED'].indexOf(getStatus) >= i ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-400'}`">
|
||||
{{ statusLabels[step] ?? step }}
|
||||
</div>
|
||||
<div v-if="i < 4" class="flex-1 h-0.5 bg-gray-200"></div>
|
||||
</template>
|
||||
</div>
|
||||
<p v-if="getStatus === 'READY'" class="mt-3 text-sm text-teal-600 font-medium">
|
||||
Your document is ready for pickup at the barangay hall.
|
||||
</p>
|
||||
<p v-else-if="getStatus === 'CANCELLED'" class="mt-3 text-sm text-red-500">
|
||||
This request has been cancelled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-gray-400 py-8">Request not found.</p>
|
||||
</div>
|
||||
</template>
|
||||
180
resources/js/Pages/Barangay/ManageBlotters.vue
Normal file
180
resources/js/Pages/Barangay/ManageBlotters.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
|
||||
usePageTitle('Blotter Records');
|
||||
|
||||
const blotters = ref([]);
|
||||
const loading = ref(false);
|
||||
const statusFilter = ref('');
|
||||
const typeFilter = ref('');
|
||||
const search = ref('');
|
||||
const showModal = ref(false);
|
||||
|
||||
const form = ref({
|
||||
complainant_name: '', complainant_contact: '', complainant_address: '',
|
||||
respondent_name: '', respondent_contact: '', respondent_address: '',
|
||||
incident_type: 'AMICABLE', incident_date: '', incident_location: '', narrative: '',
|
||||
});
|
||||
|
||||
const statusLabels = {
|
||||
FILED: 'Filed', FOR_HEARING: 'For Hearing', SETTLED: 'Settled',
|
||||
RESOLVED: 'Resolved', DISMISSED: 'Dismissed', ENDORSED: 'Endorsed',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
FILED: 'bg-blue-100 text-blue-700', FOR_HEARING: 'bg-yellow-100 text-yellow-700',
|
||||
SETTLED: 'bg-green-100 text-green-700', RESOLVED: 'bg-teal-100 text-teal-700',
|
||||
DISMISSED: 'bg-gray-100 text-gray-700', ENDORSED: 'bg-purple-100 text-purple-700',
|
||||
};
|
||||
|
||||
const loadBlotters = async () => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter.value) params.append('status', statusFilter.value);
|
||||
if (typeFilter.value) params.append('incident_type', typeFilter.value);
|
||||
if (search.value) params.append('search', search.value);
|
||||
const res = await executeRequest(`/blotters?${params}`);
|
||||
if (res.success) blotters.value = res.data.data ?? res.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const submitBlotter = async () => {
|
||||
const res = await executeRequest('/blotters/create', 'POST', form.value);
|
||||
if (res.success) {
|
||||
showModal.value = false;
|
||||
form.value = {
|
||||
complainant_name: '', complainant_contact: '', complainant_address: '',
|
||||
respondent_name: '', respondent_contact: '', respondent_address: '',
|
||||
incident_type: 'AMICABLE', incident_date: '', incident_location: '', narrative: '',
|
||||
};
|
||||
loadBlotters();
|
||||
}
|
||||
};
|
||||
|
||||
const viewBlotter = (item) => navigate(`/Barangay/BlotterDetail?target=${item.hashkey}`);
|
||||
|
||||
onMounted(loadBlotters);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Blotter Records</h1>
|
||||
<button @click="showModal = true" class="btn-primary">+ File Blotter</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input v-model="search" @input="loadBlotters" placeholder="Search blotter no. or name..." class="input flex-1" />
|
||||
<select v-model="statusFilter" @change="loadBlotters" class="input w-36">
|
||||
<option value="">All Status</option>
|
||||
<option v-for="(label, val) in statusLabels" :key="val" :value="val">{{ label }}</option>
|
||||
</select>
|
||||
<select v-model="typeFilter" @change="loadBlotters" class="input w-36">
|
||||
<option value="">All Types</option>
|
||||
<option value="AMICABLE">Amicable</option>
|
||||
<option value="UNLAWFUL">Unlawful</option>
|
||||
<option value="MINOR">Minor</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left p-2">Blotter No.</th>
|
||||
<th class="text-left p-2">Complainant</th>
|
||||
<th class="text-left p-2">Respondent</th>
|
||||
<th class="text-left p-2">Type</th>
|
||||
<th class="text-left p-2">Date</th>
|
||||
<th class="text-left p-2">Status</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in blotters" :key="b.id" class="border-b hover:bg-gray-50">
|
||||
<td class="p-2 font-mono text-xs">{{ b.blotter_no }}</td>
|
||||
<td class="p-2">{{ b.complainant_name }}</td>
|
||||
<td class="p-2">{{ b.respondent_name }}</td>
|
||||
<td class="p-2 capitalize">{{ b.incident_type?.toLowerCase() }}</td>
|
||||
<td class="p-2">{{ b.incident_date }}</td>
|
||||
<td class="p-2">
|
||||
<span :class="`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[b.status?.value ?? b.status] ?? 'bg-gray-100'}`">
|
||||
{{ statusLabels[b.status?.value ?? b.status] ?? b.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<button @click="viewBlotter(b)" class="btn-sm">View</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!blotters.length" class="text-center text-gray-400 py-8">No blotter records found.</p>
|
||||
</div>
|
||||
|
||||
<!-- File Blotter Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h2 class="text-lg font-bold mb-4">File a Blotter</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2 font-semibold text-gray-600 border-b pb-1">Complainant</div>
|
||||
<div>
|
||||
<label class="label">Name *</label>
|
||||
<input v-model="form.complainant_name" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contact</label>
|
||||
<input v-model="form.complainant_contact" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Address</label>
|
||||
<input v-model="form.complainant_address" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2 font-semibold text-gray-600 border-b pb-1 mt-2">Respondent</div>
|
||||
<div>
|
||||
<label class="label">Name *</label>
|
||||
<input v-model="form.respondent_name" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contact</label>
|
||||
<input v-model="form.respondent_contact" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Address</label>
|
||||
<input v-model="form.respondent_address" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2 font-semibold text-gray-600 border-b pb-1 mt-2">Incident Details</div>
|
||||
<div>
|
||||
<label class="label">Incident Type *</label>
|
||||
<select v-model="form.incident_type" class="input">
|
||||
<option value="AMICABLE">Amicable</option>
|
||||
<option value="UNLAWFUL">Unlawful</option>
|
||||
<option value="MINOR">Minor</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Incident Date *</label>
|
||||
<input v-model="form.incident_date" type="date" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Location</label>
|
||||
<input v-model="form.incident_location" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Narrative *</label>
|
||||
<textarea v-model="form.narrative" class="input h-24" placeholder="Describe the incident in detail..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="submitBlotter" class="btn-primary">File Blotter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
113
resources/js/Pages/Barangay/ManageDocumentRequests.vue
Normal file
113
resources/js/Pages/Barangay/ManageDocumentRequests.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
|
||||
usePageTitle('Document Requests');
|
||||
|
||||
const requests = ref([]);
|
||||
const loading = ref(false);
|
||||
const statusFilter = ref('');
|
||||
const search = ref('');
|
||||
|
||||
const statusLabels = {
|
||||
DRAFT: 'Draft', PENDING_PAYMENT: 'Pending Payment', PAID: 'Paid',
|
||||
PROCESSING: 'Processing', READY: 'Ready for Pickup', CLAIMED: 'Claimed', CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
PENDING_PAYMENT: 'bg-yellow-100 text-yellow-700',
|
||||
PAID: 'bg-blue-100 text-blue-700',
|
||||
PROCESSING: 'bg-orange-100 text-orange-700',
|
||||
READY: 'bg-teal-100 text-teal-700',
|
||||
CLAIMED: 'bg-green-100 text-green-700',
|
||||
CANCELLED: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const loadRequests = async () => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter.value) params.append('status', statusFilter.value);
|
||||
if (search.value) params.append('search', search.value);
|
||||
const res = await executeRequest(`/admin/documents?${params}`);
|
||||
if (res.success) requests.value = res.data.data ?? res.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const viewRequest = (item) => navigate(`/Barangay/DocumentRequestDetail?target=${item.hashkey}`);
|
||||
|
||||
const quickAction = async (hashkey, action) => {
|
||||
const urlMap = {
|
||||
confirm: '/admin/documents/confirm-payment',
|
||||
ready: '/admin/documents/mark-ready',
|
||||
claim: '/admin/documents/mark-claimed',
|
||||
};
|
||||
await executeRequest(urlMap[action], 'POST', { target: hashkey, method: 'CASH' });
|
||||
loadRequests();
|
||||
};
|
||||
|
||||
const getStatus = (item) => item.status?.value ?? item.status;
|
||||
|
||||
onMounted(loadRequests);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Document Requests</h1>
|
||||
<button @click="navigate('/Barangay/RequestDocument')" class="btn-primary">+ New Request</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input v-model="search" @input="loadRequests" placeholder="Search request no..." class="input flex-1" />
|
||||
<select v-model="statusFilter" @change="loadRequests" class="input w-44">
|
||||
<option value="">All Status</option>
|
||||
<option v-for="(label, val) in statusLabels" :key="val" :value="val">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left p-2">Request No.</th>
|
||||
<th class="text-left p-2">Type</th>
|
||||
<th class="text-left p-2">Resident</th>
|
||||
<th class="text-left p-2">Fee</th>
|
||||
<th class="text-left p-2">Payment</th>
|
||||
<th class="text-left p-2">Status</th>
|
||||
<th class="text-left p-2">Date</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in requests" :key="r.id" class="border-b hover:bg-gray-50">
|
||||
<td class="p-2 font-mono text-xs">{{ r.request_no }}</td>
|
||||
<td class="p-2">{{ r.request_type?.name }}</td>
|
||||
<td class="p-2">{{ r.resident?.name }}</td>
|
||||
<td class="p-2">₱{{ Number(r.fee_amount).toFixed(2) }}</td>
|
||||
<td class="p-2 capitalize text-xs">
|
||||
{{ (r.payment_status?.value ?? r.payment_status)?.toLowerCase() }}
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<span :class="`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[getStatus(r)] ?? 'bg-gray-100'}`">
|
||||
{{ statusLabels[getStatus(r)] ?? getStatus(r) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 text-xs text-gray-500">{{ r.created_at?.slice(0, 10) }}</td>
|
||||
<td class="p-2 flex gap-1">
|
||||
<button @click="viewRequest(r)" class="btn-sm">View</button>
|
||||
<button v-if="getStatus(r) === 'PENDING_PAYMENT'" @click="quickAction(r.hashkey, 'confirm')" class="btn-sm bg-blue-500 text-white">Pay</button>
|
||||
<button v-if="getStatus(r) === 'PROCESSING'" @click="quickAction(r.hashkey, 'ready')" class="btn-sm bg-teal-500 text-white">Ready</button>
|
||||
<button v-if="getStatus(r) === 'READY'" @click="quickAction(r.hashkey, 'claim')" class="btn-sm bg-green-500 text-white">Claimed</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!requests.length" class="text-center text-gray-400 py-8">No document requests found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
212
resources/js/Pages/Barangay/ManageHouseholds.vue
Normal file
212
resources/js/Pages/Barangay/ManageHouseholds.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Manage Households');
|
||||
|
||||
const households = ref([]);
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const showMemberModal = ref(false);
|
||||
const editingItem = ref(null);
|
||||
const selectedHousehold = ref(null);
|
||||
const residentSearch = ref('');
|
||||
const residentResults = ref([]);
|
||||
const pagination = ref({});
|
||||
const currentPage = ref(1);
|
||||
const search = ref('');
|
||||
|
||||
const blankForm = () => ({
|
||||
address: '', purok: '', ownership_type: 'OWNED',
|
||||
has_electricity: false, has_water: false, notes: '',
|
||||
});
|
||||
const form = ref(blankForm());
|
||||
|
||||
const ownershipTypes = ['OWNED', 'RENTED', 'SHARED'];
|
||||
|
||||
const loadHouseholds = async (page = 1) => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams({ page, search: search.value }).toString();
|
||||
const res = await executeRequest(`/households?${params}`);
|
||||
if (res.success) {
|
||||
households.value = res.data.data ?? res.data;
|
||||
pagination.value = res.data;
|
||||
currentPage.value = page;
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingItem.value = null;
|
||||
form.value = blankForm();
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
editingItem.value = item;
|
||||
form.value = { ...item };
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const url = editingItem.value ? '/households/update' : '/households/create';
|
||||
const payload = editingItem.value ? { target: editingItem.value.hashkey, ...form.value } : form.value;
|
||||
const res = await executeRequest(url, 'POST', payload);
|
||||
if (res.success) { showModal.value = false; loadHouseholds(); }
|
||||
};
|
||||
|
||||
const openMembers = (household) => {
|
||||
selectedHousehold.value = household;
|
||||
residentSearch.value = '';
|
||||
residentResults.value = [];
|
||||
showMemberModal.value = true;
|
||||
};
|
||||
|
||||
const searchResidents = async () => {
|
||||
if (residentSearch.value.length < 2) return;
|
||||
const res = await executeRequest(`/residents/search?q=${encodeURIComponent(residentSearch.value)}`);
|
||||
if (res.success) residentResults.value = res.data;
|
||||
};
|
||||
|
||||
const addMember = async (resident) => {
|
||||
await executeRequest('/households/members/add', 'POST', {
|
||||
household: selectedHousehold.value.hashkey,
|
||||
resident: resident.hashkey,
|
||||
});
|
||||
residentResults.value = [];
|
||||
residentSearch.value = '';
|
||||
loadHouseholds();
|
||||
};
|
||||
|
||||
const removeMember = async (household, residentHashkey) => {
|
||||
if (!confirm('Remove this member from household?')) return;
|
||||
await executeRequest('/households/members/remove', 'POST', {
|
||||
household: household.hashkey,
|
||||
resident: residentHashkey,
|
||||
});
|
||||
loadHouseholds();
|
||||
};
|
||||
|
||||
onMounted(() => loadHouseholds());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Households</h1>
|
||||
<button @click="openCreate" class="btn-primary">+ New Household</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input v-model="search" @keyup.enter="loadHouseholds(1)" placeholder="Search address or household no..." class="input flex-1" />
|
||||
<button @click="loadHouseholds(1)" class="btn-secondary">Search</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="h in households" :key="h.id" class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono text-sm font-semibold text-blue-600">{{ h.household_no }}</span>
|
||||
<span class="text-xs bg-gray-100 px-2 py-0.5 rounded">{{ h.ownership_type }}</span>
|
||||
<span v-if="h.has_electricity" class="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded">⚡ Power</span>
|
||||
<span v-if="h.has_water" class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">💧 Water</span>
|
||||
</div>
|
||||
<p class="text-gray-700">{{ h.address }}</p>
|
||||
<p v-if="h.purok" class="text-sm text-gray-500">Purok {{ h.purok }}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">{{ h.members_count ?? 0 }} member(s)</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="openMembers(h)" class="btn-sm">Members</button>
|
||||
<button @click="openEdit(h)" class="btn-sm">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members list inline -->
|
||||
<div v-if="h.members && h.members.length" class="mt-3 pt-3 border-t">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="m in h.members" :key="m.id"
|
||||
class="flex items-center gap-1 bg-gray-50 border rounded px-2 py-1 text-sm">
|
||||
{{ m.fullname ?? `${m.first_name} ${m.last_name}` }}
|
||||
<button @click="removeMember(h, m.hashkey)" class="text-red-400 hover:text-red-600 ml-1">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!households.length" class="text-center text-gray-400 py-8">No households found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination.last_page > 1" class="flex justify-center gap-2 mt-4">
|
||||
<button v-for="p in pagination.last_page" :key="p"
|
||||
@click="loadHouseholds(p)"
|
||||
:class="`btn-sm ${p === currentPage ? 'btn-primary' : ''}`">{{ p }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingItem ? 'Edit Household' : 'New Household' }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="label">Address *</label>
|
||||
<input v-model="form.address" class="input" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Purok</label>
|
||||
<input v-model="form.purok" class="input" placeholder="e.g. 1, 2A" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Ownership Type</label>
|
||||
<select v-model="form.ownership_type" class="input">
|
||||
<option v-for="o in ownershipTypes" :key="o" :value="o">{{ o }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input v-model="form.has_electricity" type="checkbox" class="rounded" />
|
||||
<span class="text-sm">Has Electricity</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input v-model="form.has_water" type="checkbox" class="rounded" />
|
||||
<span class="text-sm">Has Water</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Notes</label>
|
||||
<textarea v-model="form.notes" class="input h-16"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="save" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showMemberModal" class="modal-overlay" @click.self="showMemberModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-3">Add Member to {{ selectedHousehold?.household_no }}</h2>
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input v-model="residentSearch" @input="searchResidents" placeholder="Search resident name..." class="input flex-1" />
|
||||
</div>
|
||||
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||
<div v-for="r in residentResults" :key="r.id"
|
||||
class="flex justify-between items-center bg-gray-50 rounded p-2">
|
||||
<span class="text-sm">{{ r.fullname ?? `${r.first_name} ${r.last_name}` }}</span>
|
||||
<button @click="addMember(r)" class="btn-sm btn-primary text-xs">Add</button>
|
||||
</div>
|
||||
<p v-if="!residentResults.length && residentSearch.length >= 2" class="text-center text-gray-400 text-sm py-2">No results.</p>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<button @click="showMemberModal = false" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
193
resources/js/Pages/Barangay/ManageProjects.vue
Normal file
193
resources/js/Pages/Barangay/ManageProjects.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Barangay Projects');
|
||||
|
||||
const projects = ref([]);
|
||||
const summary = ref(null);
|
||||
const loading = ref(false);
|
||||
const statusFilter = ref('');
|
||||
const typeFilter = ref('');
|
||||
const showModal = ref(false);
|
||||
const editingItem = ref(null);
|
||||
|
||||
const blankForm = () => ({
|
||||
project_name: '', description: '', type: 'INFRASTRUCTURE',
|
||||
budget: '', fund_source: 'GENERAL_FUND',
|
||||
start_date: '', end_date: '',
|
||||
implementing_office: '', contractor: '', location: '', beneficiaries_count: '',
|
||||
});
|
||||
const form = ref(blankForm());
|
||||
|
||||
const projectTypes = ['INFRASTRUCTURE', 'LIVELIHOOD', 'HEALTH', 'EDUCATION', 'ENVIRONMENT', 'OTHERS'];
|
||||
const fundSources = ['GENERAL_FUND', 'SK', 'PROVINCE', 'NATIONAL', 'OTHERS'];
|
||||
const statusOptions = ['PLANNED', 'ONGOING', 'COMPLETED', 'SUSPENDED', 'CANCELLED'];
|
||||
|
||||
const statusColors = {
|
||||
PLANNED: 'bg-blue-100 text-blue-700', ONGOING: 'bg-yellow-100 text-yellow-700',
|
||||
COMPLETED: 'bg-green-100 text-green-700', SUSPENDED: 'bg-orange-100 text-orange-700',
|
||||
CANCELLED: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter.value) params.append('status', statusFilter.value);
|
||||
if (typeFilter.value) params.append('type', typeFilter.value);
|
||||
const [projRes, sumRes] = await Promise.all([
|
||||
executeRequest(`/projects?${params}`),
|
||||
executeRequest('/projects/summary'),
|
||||
]);
|
||||
if (projRes.success) projects.value = projRes.data.data ?? projRes.data;
|
||||
if (sumRes.success) summary.value = sumRes.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openCreate = () => { editingItem.value = null; form.value = blankForm(); showModal.value = true; };
|
||||
const openEdit = (item) => { editingItem.value = item; form.value = { ...item }; showModal.value = true; };
|
||||
|
||||
const saveProject = async () => {
|
||||
const url = editingItem.value ? '/projects/update' : '/projects/create';
|
||||
const payload = editingItem.value ? { target: editingItem.value.hashkey, ...form.value } : form.value;
|
||||
const res = await executeRequest(url, 'POST', payload);
|
||||
if (res.success) { showModal.value = false; loadData(); }
|
||||
};
|
||||
|
||||
const updateStatus = async (item, status) => {
|
||||
await executeRequest('/projects/status', 'POST', { target: item.hashkey, status });
|
||||
loadData();
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Barangay Projects</h1>
|
||||
<button @click="openCreate" class="btn-primary">+ Add Project</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div v-if="summary" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div class="bg-white rounded-lg shadow p-3 text-center">
|
||||
<div class="text-2xl font-bold">{{ summary.total }}</div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div class="bg-yellow-50 rounded-lg shadow p-3 text-center">
|
||||
<div class="text-2xl font-bold text-yellow-600">{{ summary.ongoing }}</div>
|
||||
<div class="text-xs text-gray-500">Ongoing</div>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg shadow p-3 text-center">
|
||||
<div class="text-2xl font-bold text-green-600">{{ summary.completed }}</div>
|
||||
<div class="text-xs text-gray-500">Completed</div>
|
||||
</div>
|
||||
<div class="bg-blue-50 rounded-lg shadow p-3 text-center">
|
||||
<div class="text-xl font-bold text-blue-600">₱{{ Number(summary.total_budget ?? 0).toLocaleString() }}</div>
|
||||
<div class="text-xs text-gray-500">Total Budget</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<select v-model="statusFilter" @change="loadData" class="input w-36">
|
||||
<option value="">All Status</option>
|
||||
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<select v-model="typeFilter" @change="loadData" class="input w-40">
|
||||
<option value="">All Types</option>
|
||||
<option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div v-for="p in projects" :key="p.id" class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-semibold">{{ p.project_name }}</h3>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ p.type }} | {{ p.fund_source?.replace('_', ' ') }}</div>
|
||||
<div class="text-sm text-gray-700 mt-1">Budget: <strong>₱{{ Number(p.budget).toLocaleString() }}</strong></div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ p.start_date }} — {{ p.end_date || 'TBD' }}</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<span :class="`px-2 py-0.5 rounded-full text-xs font-medium ${statusColors[p.status] ?? 'bg-gray-100'}`">
|
||||
{{ p.status }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<button @click="openEdit(p)" class="btn-sm">Edit</button>
|
||||
<select @change="e => updateStatus(p, e.target.value)" class="text-xs border rounded px-1">
|
||||
<option value="">Change Status</option>
|
||||
<option v-for="s in statusOptions" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="p.description" class="text-sm text-gray-600 mt-2 line-clamp-2">{{ p.description }}</p>
|
||||
</div>
|
||||
<p v-if="!projects.length" class="text-center text-gray-400 py-8">No projects found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingItem ? 'Edit Project' : 'New Project' }}</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="col-span-2">
|
||||
<label class="label">Project Name *</label>
|
||||
<input v-model="form.project_name" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Type *</label>
|
||||
<select v-model="form.type" class="input">
|
||||
<option v-for="t in projectTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Fund Source *</label>
|
||||
<select v-model="form.fund_source" class="input">
|
||||
<option v-for="f in fundSources" :key="f" :value="f">{{ f.replace('_', ' ') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Budget (₱) *</label>
|
||||
<input v-model="form.budget" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Beneficiaries Count</label>
|
||||
<input v-model="form.beneficiaries_count" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Start Date *</label>
|
||||
<input v-model="form.start_date" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Target End Date</label>
|
||||
<input v-model="form.end_date" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Implementing Office</label>
|
||||
<input v-model="form.implementing_office" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Contractor</label>
|
||||
<input v-model="form.contractor" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Location</label>
|
||||
<input v-model="form.location" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input h-20"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveProject" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
116
resources/js/Pages/Barangay/ManageRequestTypes.vue
Normal file
116
resources/js/Pages/Barangay/ManageRequestTypes.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
|
||||
usePageTitle('Document Types & Fees');
|
||||
|
||||
const types = ref([]);
|
||||
const loading = ref(false);
|
||||
const showModal = ref(false);
|
||||
const editingItem = ref(null);
|
||||
|
||||
const blankForm = () => ({
|
||||
name: '', code: '', description: '',
|
||||
base_fee: 0, processing_days: 1, requires_clearance: false,
|
||||
});
|
||||
const form = ref(blankForm());
|
||||
|
||||
const loadTypes = async () => {
|
||||
loading.value = true;
|
||||
const res = await executeRequest('/admin/request-types');
|
||||
if (res.success) types.value = res.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const openCreate = () => { editingItem.value = null; form.value = blankForm(); showModal.value = true; };
|
||||
const openEdit = (item) => { editingItem.value = item; form.value = { ...item }; showModal.value = true; };
|
||||
|
||||
const save = async () => {
|
||||
const url = editingItem.value ? '/admin/request-types/update' : '/admin/request-types/create';
|
||||
const payload = editingItem.value ? { target: editingItem.value.code, ...form.value } : form.value;
|
||||
const res = await executeRequest(url, 'POST', payload);
|
||||
if (res.success) { showModal.value = false; loadTypes(); }
|
||||
};
|
||||
|
||||
const toggleActive = async (item) => {
|
||||
await executeRequest('/admin/request-types/toggle', 'POST', { target: item.code });
|
||||
loadTypes();
|
||||
};
|
||||
|
||||
onMounted(loadTypes);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-4xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Document Types & Fee Schedule</h1>
|
||||
<button @click="openCreate" class="btn-primary">+ Add Type</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else class="grid gap-3">
|
||||
<div v-for="t in types" :key="t.id" class="bg-white rounded-lg shadow p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold">{{ t.name }}</h3>
|
||||
<span class="font-mono text-xs bg-gray-100 px-2 py-0.5 rounded">{{ t.code }}</span>
|
||||
<span v-if="!t.is_active" class="text-xs text-red-500">Inactive</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-0.5">{{ t.description }}</p>
|
||||
<div class="flex gap-4 mt-1 text-sm">
|
||||
<span class="font-semibold text-green-600">{{ t.base_fee > 0 ? `₱${t.base_fee}` : 'Free' }}</span>
|
||||
<span class="text-gray-400">{{ t.processing_days }} day(s) processing</span>
|
||||
<span v-if="t.requires_clearance" class="text-orange-500">Requires Clearance</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="openEdit(t)" class="btn-sm">Edit</button>
|
||||
<button @click="toggleActive(t)" :class="`btn-sm ${t.is_active ? 'text-red-500' : 'text-green-500'}`">
|
||||
{{ t.is_active ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!types.length" class="text-center text-gray-400 py-8">No document types defined.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingItem ? 'Edit Document Type' : 'New Document Type' }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="label">Name *</label>
|
||||
<input v-model="form.name" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Code *</label>
|
||||
<input v-model="form.code" class="input" placeholder="e.g. CLEARANCE" :disabled="!!editingItem" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Description</label>
|
||||
<textarea v-model="form.description" class="input h-16"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">Base Fee (₱)</label>
|
||||
<input v-model="form.base_fee" type="number" step="0.01" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Processing Days</label>
|
||||
<input v-model="form.processing_days" type="number" min="1" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input v-model="form.requires_clearance" type="checkbox" class="rounded" />
|
||||
<span class="text-sm">Requires prior clearance</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="save" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
213
resources/js/Pages/Barangay/ManageResidents.vue
Normal file
213
resources/js/Pages/Barangay/ManageResidents.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
|
||||
usePageTitle('Residents');
|
||||
|
||||
const residents = ref([]);
|
||||
const loading = ref(false);
|
||||
const search = ref('');
|
||||
const purokFilter = ref('');
|
||||
const puroks = ref([]);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const perPage = 20;
|
||||
|
||||
const showModal = ref(false);
|
||||
const editingItem = ref(null);
|
||||
|
||||
const blankForm = () => ({
|
||||
firstname: '', middlename: '', lastname: '', suffix: '',
|
||||
dob: '', birthplace: '', gender: 'MALE', civil_status: 'SINGLE',
|
||||
citizenship: 'Filipino', religion: '', occupation: '',
|
||||
monthly_income: '', blood_type: '',
|
||||
voter_status: false, head_of_household: false,
|
||||
purok: '', street: '', barangay: '', city: '', province: '', region: '',
|
||||
philhealth_id: '', sss_id: '', gsis_id: '', tin: '',
|
||||
emergency_contact_name: '', emergency_contact_phone: '', emergency_contact_address: '',
|
||||
});
|
||||
const form = ref(blankForm());
|
||||
|
||||
const loadResidents = async () => {
|
||||
loading.value = true;
|
||||
const params = new URLSearchParams({ page: page.value, per_page: perPage });
|
||||
if (search.value) params.append('search', search.value);
|
||||
if (purokFilter.value) params.append('purok', purokFilter.value);
|
||||
const res = await executeRequest(`/residents?${params}`);
|
||||
if (res.success) {
|
||||
residents.value = res.data.data ?? res.data;
|
||||
total.value = res.data.total ?? residents.value.length;
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const loadPuroks = async () => {
|
||||
const res = await executeRequest('/residents/puroks');
|
||||
if (res.success) puroks.value = res.data;
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingItem.value = null;
|
||||
form.value = blankForm();
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
editingItem.value = item;
|
||||
form.value = { ...item };
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const saveResident = async () => {
|
||||
const url = editingItem.value ? '/residents/update' : '/residents/create';
|
||||
const payload = editingItem.value
|
||||
? { target: editingItem.value.hashkey, ...form.value }
|
||||
: form.value;
|
||||
const res = await executeRequest(url, 'POST', payload);
|
||||
if (res.success) {
|
||||
showModal.value = false;
|
||||
loadResidents();
|
||||
}
|
||||
};
|
||||
|
||||
const viewProfile = (item) => navigate(`/Barangay/ResidentProfile?target=${item.hashkey}`);
|
||||
|
||||
onMounted(() => { loadResidents(); loadPuroks(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-6xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h1 class="text-2xl font-bold">Resident Records</h1>
|
||||
<button @click="openCreate" class="btn-primary">+ Add Resident</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<input v-model="search" @input="loadResidents" placeholder="Search by name..." class="input flex-1" />
|
||||
<select v-model="purokFilter" @change="loadResidents" class="input w-40">
|
||||
<option value="">All Puroks</option>
|
||||
<option v-for="p in puroks" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
<div v-else>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-100">
|
||||
<tr>
|
||||
<th class="text-left p-2">Name</th>
|
||||
<th class="text-left p-2">DOB</th>
|
||||
<th class="text-left p-2">Gender</th>
|
||||
<th class="text-left p-2">Purok</th>
|
||||
<th class="text-left p-2">Status</th>
|
||||
<th class="p-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in residents" :key="r.id" class="border-b hover:bg-gray-50">
|
||||
<td class="p-2 font-medium">{{ r.lastname }}, {{ r.firstname }} {{ r.middlename }}</td>
|
||||
<td class="p-2">{{ r.dob }}</td>
|
||||
<td class="p-2">{{ r.gender }}</td>
|
||||
<td class="p-2">{{ r.purok || '—' }}</td>
|
||||
<td class="p-2">
|
||||
<span :class="r.is_active ? 'badge-green' : 'badge-red'">
|
||||
{{ r.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 flex gap-2">
|
||||
<button @click="viewProfile(r)" class="btn-sm">View</button>
|
||||
<button @click="openEdit(r)" class="btn-sm">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="!residents.length" class="text-center text-gray-400 py-8">No residents found.</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
|
||||
<div class="modal-box">
|
||||
<h2 class="text-lg font-bold mb-4">{{ editingItem ? 'Edit Resident' : 'New Resident' }}</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">First Name *</label>
|
||||
<input v-model="form.firstname" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Middle Name</label>
|
||||
<input v-model="form.middlename" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Last Name *</label>
|
||||
<input v-model="form.lastname" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Suffix</label>
|
||||
<input v-model="form.suffix" class="input" placeholder="Jr., Sr., III" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Date of Birth *</label>
|
||||
<input v-model="form.dob" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Gender *</label>
|
||||
<select v-model="form.gender" class="input">
|
||||
<option value="MALE">Male</option>
|
||||
<option value="FEMALE">Female</option>
|
||||
<option value="OTHER">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Civil Status</label>
|
||||
<select v-model="form.civil_status" class="input">
|
||||
<option value="SINGLE">Single</option>
|
||||
<option value="MARRIED">Married</option>
|
||||
<option value="WIDOWED">Widowed</option>
|
||||
<option value="SEPARATED">Separated</option>
|
||||
<option value="ANNULLED">Annulled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Blood Type</label>
|
||||
<select v-model="form.blood_type" class="input">
|
||||
<option value="">—</option>
|
||||
<option v-for="bt in ['A+','A-','B+','B-','AB+','AB-','O+','O-']" :key="bt" :value="bt">{{ bt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Occupation</label>
|
||||
<input v-model="form.occupation" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Monthly Income</label>
|
||||
<input v-model="form.monthly_income" type="number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Purok</label>
|
||||
<input v-model="form.purok" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Street / Address</label>
|
||||
<input v-model="form.street" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Emergency Contact</label>
|
||||
<input v-model="form.emergency_contact_name" class="input" placeholder="Name" />
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="form.emergency_contact_phone" class="input" placeholder="Phone number" />
|
||||
</div>
|
||||
<div>
|
||||
<input v-model="form.emergency_contact_address" class="input" placeholder="Address" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="showModal = false" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveResident" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
137
resources/js/Pages/Barangay/RequestDocument.vue
Normal file
137
resources/js/Pages/Barangay/RequestDocument.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
|
||||
usePageTitle('Request a Document');
|
||||
|
||||
const requestTypes = ref([]);
|
||||
const myRequests = ref([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = ref({ request_type_id: '', purpose: '' });
|
||||
const selectedType = ref(null);
|
||||
const submitted = ref(false);
|
||||
const submittedDoc = ref(null);
|
||||
|
||||
const statusLabels = {
|
||||
DRAFT: 'Draft', PENDING_PAYMENT: 'Pending Payment', PAID: 'Paid',
|
||||
PROCESSING: 'Processing', READY: 'Ready for Pickup', CLAIMED: 'Claimed', CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true;
|
||||
const [typesRes, myRes] = await Promise.all([
|
||||
executeRequest('/request-types'),
|
||||
executeRequest('/documents/my'),
|
||||
]);
|
||||
if (typesRes.success) requestTypes.value = typesRes.data;
|
||||
if (myRes.success) myRequests.value = myRes.data;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const onTypeSelect = () => {
|
||||
selectedType.value = requestTypes.value.find(t => t.id == form.value.request_type_id);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
submitting.value = true;
|
||||
const res = await executeRequest('/documents/submit', 'POST', form.value);
|
||||
if (res.success) {
|
||||
submittedDoc.value = res.data;
|
||||
submitted.value = true;
|
||||
loadData();
|
||||
}
|
||||
submitting.value = false;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
submitted.value = false;
|
||||
submittedDoc.value = null;
|
||||
form.value = { request_type_id: '', purpose: '' };
|
||||
selectedType.value = null;
|
||||
};
|
||||
|
||||
const getStatus = (r) => r.status?.value ?? r.status;
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">Request a Document</h1>
|
||||
<p class="text-gray-500 text-sm">Submit a request for a barangay certificate or clearance.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
|
||||
<div v-else-if="!submitted">
|
||||
<div class="bg-white rounded-xl shadow p-5 mb-6">
|
||||
<h2 class="font-semibold text-gray-700 mb-3">Select Document Type</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
||||
<div
|
||||
v-for="t in requestTypes"
|
||||
:key="t.id"
|
||||
@click="form.request_type_id = t.id; onTypeSelect()"
|
||||
:class="`border rounded-lg p-3 cursor-pointer transition ${form.request_type_id == t.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`"
|
||||
>
|
||||
<div class="font-medium text-sm">{{ t.name }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">{{ t.description }}</div>
|
||||
<div class="mt-2 flex justify-between text-xs">
|
||||
<span class="font-semibold text-green-600">{{ t.base_fee > 0 ? `₱${t.base_fee}` : 'Free' }}</span>
|
||||
<span class="text-gray-400">{{ t.processing_days }} day(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType" class="mt-2">
|
||||
<label class="label">Purpose / Reason *</label>
|
||||
<textarea v-model="form.purpose" class="input h-20" :placeholder="`State your purpose for requesting ${selectedType.name}...`"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="!form.request_type_id || !form.purpose || submitting"
|
||||
class="btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ submitting ? 'Submitting...' : 'Submit Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl shadow p-5 mb-6">
|
||||
<div class="text-center">
|
||||
<div class="text-4xl mb-2">✅</div>
|
||||
<h2 class="text-xl font-bold text-green-600">Request Submitted!</h2>
|
||||
<p class="text-gray-600 mt-1">Request No: <span class="font-mono font-bold">{{ submittedDoc?.request_no }}</span></p>
|
||||
<p class="text-gray-500 text-sm mt-2">
|
||||
{{ submittedDoc?.fee_amount > 0 ? `Please proceed to the barangay hall to pay ₱${submittedDoc.fee_amount}.` : 'Your request is free and will be processed shortly.' }}
|
||||
</p>
|
||||
<button @click="resetForm" class="btn-primary mt-4">Request Another</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Requests -->
|
||||
<div class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3">My Requests</h2>
|
||||
<div v-if="myRequests.length">
|
||||
<div v-for="r in myRequests" :key="r.id" class="border-b py-3 flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-medium text-sm">{{ r.request_type?.name }}</div>
|
||||
<div class="text-xs text-gray-400 font-mono">{{ r.request_no }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">{{ r.created_at?.slice(0, 10) }}</div>
|
||||
</div>
|
||||
<span :class="`px-2 py-1 rounded-full text-xs font-medium ${getStatus(r) === 'CLAIMED' ? 'bg-green-100 text-green-700' : getStatus(r) === 'READY' ? 'bg-teal-100 text-teal-700' : 'bg-gray-100 text-gray-700'}`">
|
||||
{{ statusLabels[getStatus(r)] ?? getStatus(r) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 text-sm">No previous requests.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
238
resources/js/Pages/Barangay/ResidentProfile.vue
Normal file
238
resources/js/Pages/Barangay/ResidentProfile.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { usePageTitle } from '../../composables/Core/usePageTitle';
|
||||
import { executeRequest } from '../../utils/executeRequest.js';
|
||||
import { navigate } from '../../utils/navigate.js';
|
||||
|
||||
usePageTitle('Resident Profile');
|
||||
|
||||
const resident = ref(null);
|
||||
const loading = ref(false);
|
||||
const editMode = ref(false);
|
||||
const form = ref({});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const target = urlParams.get('target');
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!resident.value) return '';
|
||||
return resident.value.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700';
|
||||
});
|
||||
|
||||
const loadProfile = async () => {
|
||||
loading.value = true;
|
||||
const res = await executeRequest('/residents/show', 'POST', { target });
|
||||
if (res.success) {
|
||||
resident.value = res.data;
|
||||
form.value = { ...res.data };
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
form.value = { ...resident.value };
|
||||
editMode.value = true;
|
||||
};
|
||||
|
||||
const cancelEdit = () => { editMode.value = false; };
|
||||
|
||||
const saveEdit = async () => {
|
||||
const res = await executeRequest('/residents/update', 'POST', { target, ...form.value });
|
||||
if (res.success) {
|
||||
resident.value = res.data ?? { ...resident.value, ...form.value };
|
||||
editMode.value = false;
|
||||
loadProfile();
|
||||
}
|
||||
};
|
||||
|
||||
const fullname = computed(() => {
|
||||
if (!resident.value) return '';
|
||||
const r = resident.value;
|
||||
return [r.first_name, r.middle_name, r.last_name, r.suffix].filter(Boolean).join(' ');
|
||||
});
|
||||
|
||||
onMounted(loadProfile);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 max-w-3xl mx-auto">
|
||||
<button @click="navigate('/barangay/manageresidents')" class="text-sm text-blue-500 mb-4 inline-flex items-center gap-1">
|
||||
← Back to Residents
|
||||
</button>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">Loading...</div>
|
||||
|
||||
<div v-else-if="resident">
|
||||
<!-- Header -->
|
||||
<div class="bg-white rounded-xl shadow p-5 mb-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">{{ fullname }}</h1>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span :class="`text-xs px-2 py-0.5 rounded-full font-medium ${statusColor}`">
|
||||
{{ resident.is_active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span v-if="resident.voter_status" class="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Registered Voter</span>
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm mt-1">Purok {{ resident.purok ?? '—' }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button v-if="!editMode" @click="startEdit" class="btn-secondary">Edit</button>
|
||||
<template v-else>
|
||||
<button @click="cancelEdit" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveEdit" class="btn-primary">Save</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Mode -->
|
||||
<div v-if="!editMode" class="space-y-4">
|
||||
<div class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3 border-b pb-2">Personal Information</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">Date of Birth:</span> {{ resident.date_of_birth ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Gender:</span> {{ resident.gender ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Civil Status:</span> {{ resident.civil_status ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Blood Type:</span> {{ resident.blood_type ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Nationality:</span> {{ resident.nationality ?? 'Filipino' }}</div>
|
||||
<div><span class="text-gray-400">Religion:</span> {{ resident.religion ?? '—' }}</div>
|
||||
<div class="col-span-2"><span class="text-gray-400">Address:</span> {{ resident.address ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Mobile:</span> {{ resident.mobile_number ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Email:</span> {{ resident.email ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Occupation:</span> {{ resident.occupation ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Educational Attainment:</span> {{ resident.educational_attainment ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3 border-b pb-2">Government IDs & Voter Info</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">PhilHealth:</span> {{ resident.philhealth_id ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">SSS:</span> {{ resident.sss_id ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">GSIS:</span> {{ resident.gsis_id ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">TIN:</span> {{ resident.tin ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Voter ID:</span> {{ resident.registered_voter_id ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Voter Precinct:</span> {{ resident.voter_precinct ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow p-5">
|
||||
<h2 class="font-semibold text-gray-700 mb-3 border-b pb-2">Emergency Contact</h2>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span class="text-gray-400">Name:</span> {{ resident.emergency_contact_name ?? '—' }}</div>
|
||||
<div><span class="text-gray-400">Relationship:</span> {{ resident.emergency_contact_relationship ?? '—' }}</div>
|
||||
<div class="col-span-2"><span class="text-gray-400">Mobile:</span> {{ resident.emergency_contact_mobile ?? '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Mode -->
|
||||
<div v-else class="bg-white rounded-xl shadow p-5 space-y-4">
|
||||
<h2 class="font-semibold text-gray-700 border-b pb-2">Edit Resident Information</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="label">First Name *</label>
|
||||
<input v-model="form.first_name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Middle Name</label>
|
||||
<input v-model="form.middle_name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Last Name *</label>
|
||||
<input v-model="form.last_name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Suffix</label>
|
||||
<input v-model="form.suffix" class="input" placeholder="Jr., Sr., III" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Date of Birth</label>
|
||||
<input v-model="form.date_of_birth" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Gender</label>
|
||||
<select v-model="form.gender" class="input">
|
||||
<option value="">Select</option>
|
||||
<option>MALE</option>
|
||||
<option>FEMALE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Civil Status</label>
|
||||
<select v-model="form.civil_status" class="input">
|
||||
<option value="">Select</option>
|
||||
<option>SINGLE</option>
|
||||
<option>MARRIED</option>
|
||||
<option>WIDOWED</option>
|
||||
<option>SEPARATED</option>
|
||||
<option>ANNULLED</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Blood Type</label>
|
||||
<select v-model="form.blood_type" class="input">
|
||||
<option value="">Unknown</option>
|
||||
<option v-for="bt in ['A+','A-','B+','B-','AB+','AB-','O+','O-']" :key="bt" :value="bt">{{ bt }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Mobile Number</label>
|
||||
<input v-model="form.mobile_number" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Email</label>
|
||||
<input v-model="form.email" type="email" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Purok</label>
|
||||
<input v-model="form.purok" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Occupation</label>
|
||||
<input v-model="form.occupation" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Address</label>
|
||||
<input v-model="form.address" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">PhilHealth ID</label>
|
||||
<input v-model="form.philhealth_id" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">SSS No.</label>
|
||||
<input v-model="form.sss_id" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">TIN</label>
|
||||
<input v-model="form.tin" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Voter ID</label>
|
||||
<input v-model="form.registered_voter_id" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Emergency Contact Name</label>
|
||||
<input v-model="form.emergency_contact_name" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Emergency Contact Mobile</label>
|
||||
<input v-model="form.emergency_contact_mobile" class="input" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">Emergency Contact Relationship</label>
|
||||
<input v-model="form.emergency_contact_relationship" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button @click="cancelEdit" class="btn-secondary">Cancel</button>
|
||||
<button @click="saveEdit" class="btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-center text-gray-400 py-8">Resident not found.</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,206 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, 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 BackButton from '../Components/Core/BackButton.vue'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
|
||||
const props = defineProps({
|
||||
target: String
|
||||
})
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
usePageTitle('Batch Add Cooperative Members')
|
||||
|
||||
const ROLES = ['MEMBER', 'OFFICER', 'ADMIN']
|
||||
const MEMBERSHIP_TYPES = ['REGULAR', 'ASSOCIATE', 'LABORATORY']
|
||||
|
||||
const blankRow = () => ({
|
||||
username: '',
|
||||
name: '',
|
||||
mobile_number: '',
|
||||
password: 'Password123!',
|
||||
role: 'MEMBER',
|
||||
membership_type: '',
|
||||
})
|
||||
|
||||
const cooperative = ref(null)
|
||||
const loadingCoop = ref(true)
|
||||
const members = ref([blankRow()])
|
||||
const saving = ref(false)
|
||||
|
||||
const addRow = () => {
|
||||
members.value.push(blankRow())
|
||||
}
|
||||
|
||||
const removeRow = (index) => {
|
||||
if (members.value.length > 1) {
|
||||
members.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCooperative = async () => {
|
||||
if (!props.target) {
|
||||
loadingCoop.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/Get', { hashkey: props.target })
|
||||
if (response.data?.success) {
|
||||
cooperative.value = response.data.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load cooperative', err)
|
||||
} finally {
|
||||
loadingCoop.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveMembers = async () => {
|
||||
if (!props.target) {
|
||||
modal.open({ title: 'Error', body: 'Cooperative not specified.' })
|
||||
return
|
||||
}
|
||||
const invalid = members.value.some(m => !m.username || !m.name || !m.mobile_number || !m.password)
|
||||
if (invalid) {
|
||||
modal.open({
|
||||
title: 'Validation Error',
|
||||
body: 'Please fill in Username, Name, Mobile, and Password for all rows.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/batch/cooperative-members', {
|
||||
cooperative_hash: props.target,
|
||||
members: members.value
|
||||
})
|
||||
if (response.data?.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: `Successfully registered ${response.data.count} members with new user accounts.`,
|
||||
onClose: () => navigate({ page: 'CooperativeDetail', props: { target: props.target } })
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving batch members:', err)
|
||||
const errorMessage = err.response?.data?.errors
|
||||
? err.response.data.errors.join('<br>')
|
||||
: (err.response?.data?.message || 'Failed to save members.')
|
||||
modal.open({ title: 'Error', body: errorMessage })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchCooperative)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-add-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-3">
|
||||
<BackButton :to="{ page: 'CooperativeDetail', props: { target: props.target } }" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="fw_6 mb-1">Batch Add Cooperative Members</h3>
|
||||
<p class="text-muted small mb-0">
|
||||
<span v-if="loadingCoop">Loading cooperative...</span>
|
||||
<span v-else-if="cooperative">
|
||||
Adding members to <strong>{{ cooperative.name }}</strong>. Each row creates a new user account and enrolls them as a member. Default password: <code>Password123!</code>
|
||||
</span>
|
||||
<span v-else class="text-danger">Cooperative not found.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div v-for="(m, index) in members" :key="index" class="col-md-6 col-lg-4">
|
||||
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
|
||||
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
|
||||
:disabled="members.length <= 1"><i class="fas fa-times-circle"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Username *</label>
|
||||
<input v-model="m.username" type="text" class="form-control form-control-sm" placeholder="Unique username">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Full Name *</label>
|
||||
<input v-model="m.name" type="text" class="form-control form-control-sm" placeholder="Member's full name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Mobile *</label>
|
||||
<input v-model="m.mobile_number" type="text" class="form-control form-control-sm" placeholder="09xxxxxxxxx">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Password *</label>
|
||||
<input v-model="m.password" type="text" class="form-control form-control-sm" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Role</label>
|
||||
<select v-model="m.role" class="form-select form-select-sm">
|
||||
<option v-for="r in ROLES" :key="r" :value="r">{{ r }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Membership Type</label>
|
||||
<select v-model="m.membership_type" class="form-select form-select-sm">
|
||||
<option value="">—</option>
|
||||
<option v-for="t in MEMBERSHIP_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Another Member
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3 pt-3 border-top">
|
||||
<button @click="saveMembers" :disabled="saving || !cooperative" class="btn btn-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
{{ saving ? 'Saving...' : 'Save All Members' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-add-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.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); }
|
||||
: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 {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,201 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } 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 BackButton from '../Components/Core/BackButton.vue'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
usePageTitle('Batch Add Cooperatives')
|
||||
|
||||
const COOPERATIVE_TYPES = ['AGRICULTURAL', 'CREDIT', 'CONSUMERS', 'MARKETING', 'SERVICE', 'MULTIPURPOSE']
|
||||
const COOPERATIVE_CATEGORIES = ['MICRO', 'SMALL', 'MEDIUM', 'LARGE']
|
||||
|
||||
const blankRow = () => ({
|
||||
name: '',
|
||||
address: '',
|
||||
registration_number: '',
|
||||
cin: '',
|
||||
tin: '',
|
||||
cooperative_type: '',
|
||||
cooperative_category: '',
|
||||
registration_date: '',
|
||||
contact_person: '',
|
||||
contact_number: '',
|
||||
contact_email: '',
|
||||
})
|
||||
|
||||
const cooperatives = ref([blankRow()])
|
||||
const saving = ref(false)
|
||||
|
||||
const addRow = () => {
|
||||
cooperatives.value.push(blankRow())
|
||||
}
|
||||
|
||||
const removeRow = (index) => {
|
||||
if (cooperatives.value.length > 1) cooperatives.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const saveCooperatives = async () => {
|
||||
const invalid = cooperatives.value.some(c => !c.name || !c.name.trim())
|
||||
if (invalid) {
|
||||
modal.open({
|
||||
title: 'Validation Error',
|
||||
body: 'Cooperative Name is required for all rows.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/batch/cooperatives', { cooperatives: cooperatives.value })
|
||||
if (response.data && response.data.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: `Successfully added ${response.data.count} cooperatives.`,
|
||||
onClose: () => navigate({ page: 'CooperativeList' })
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving batch cooperatives:', err)
|
||||
const errorMessage = err.response?.data?.errors
|
||||
? err.response.data.errors.join('<br>')
|
||||
: (err.response?.data?.message || 'Failed to save cooperatives.')
|
||||
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: errorMessage
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-add-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-3">
|
||||
<BackButton to="CooperativeList" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="fw_6 mb-1">Batch Add Cooperatives</h3>
|
||||
<p class="text-muted small mb-0">Register multiple cooperatives at once. Ideal for large-scale onboarding.</p>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Cooperative
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div v-for="(coop, index) in cooperatives" :key="index" class="col-md-6 col-lg-4">
|
||||
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
|
||||
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
|
||||
:disabled="cooperatives.length <= 1"><i class="fas fa-times-circle"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Name *</label>
|
||||
<input v-model="coop.name" type="text" class="form-control form-control-sm" placeholder="Cooperative name">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Address</label>
|
||||
<input v-model="coop.address" type="text" class="form-control form-control-sm" placeholder="Address">
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Registration #</label>
|
||||
<input v-model="coop.registration_number" type="text" class="form-control form-control-sm" placeholder="REG-12345">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">CIN</label>
|
||||
<input v-model="coop.cin" type="text" class="form-control form-control-sm" placeholder="CIN">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">TIN</label>
|
||||
<input v-model="coop.tin" type="text" class="form-control form-control-sm" placeholder="TIN">
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Type</label>
|
||||
<select v-model="coop.cooperative_type" class="form-select form-select-sm">
|
||||
<option value="">Select Type</option>
|
||||
<option v-for="t in COOPERATIVE_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Category</label>
|
||||
<select v-model="coop.cooperative_category" class="form-select form-select-sm">
|
||||
<option value="">Select Category</option>
|
||||
<option v-for="c in COOPERATIVE_CATEGORIES" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Registration Date</label>
|
||||
<input v-model="coop.registration_date" type="date" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Contact Person</label>
|
||||
<input v-model="coop.contact_person" type="text" class="form-control form-control-sm" placeholder="Full name">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Contact Number</label>
|
||||
<input v-model="coop.contact_number" type="text" class="form-control form-control-sm" placeholder="09123456789">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Contact Email</label>
|
||||
<input v-model="coop.contact_email" type="email" class="form-control form-control-sm" placeholder="email@example.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Another Cooperative
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3 pt-3 border-top">
|
||||
<button @click="saveCooperatives" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
{{ saving ? 'Saving...' : 'Save All Cooperatives' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-add-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.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); }
|
||||
: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 {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,774 +0,0 @@
|
||||
<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>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, 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 BackButton from '../Components/Core/BackButton.vue'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
usePageTitle('Batch Add Stores')
|
||||
|
||||
const stores = ref([
|
||||
{ name: '', description: '', address: '', category: '', subcategory: '', owner_hash: '' }
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const categories = ref([])
|
||||
const owners = ref([])
|
||||
|
||||
const addRow = () => {
|
||||
stores.value.push({ name: '', description: '', address: '', category: '', subcategory: '', owner_hash: '' })
|
||||
}
|
||||
|
||||
const removeRow = (index) => {
|
||||
if (stores.value.length > 1) {
|
||||
stores.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [catRes, ownerRes] = await Promise.all([
|
||||
axios.post('/Store/New/Category/Datalist'),
|
||||
axios.post('/admin/user/list/numbers/hash')
|
||||
])
|
||||
|
||||
if (catRes.data) categories.value = catRes.data
|
||||
if (ownerRes.data) owners.value = ownerRes.data
|
||||
} catch (err) {
|
||||
console.error('Error fetching store metadata:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const saveStores = async () => {
|
||||
const invalid = stores.value.some(s => !s.name || !s.description || !s.address)
|
||||
if (invalid) {
|
||||
modal.open({
|
||||
title: 'Validation Error',
|
||||
body: 'Please fill in all required fields (Name, Description, Address) for all rows.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/batch/stores', { stores: stores.value })
|
||||
if (response.data && response.data.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: `Successfully added ${response.data.count} stores.`,
|
||||
onClose: () => navigate({ page: 'ManageStoresAdmin' })
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving batch stores:', err)
|
||||
const errorMessage = err.response?.data?.errors
|
||||
? err.response.data.errors.join('<br>')
|
||||
: (err.response?.data?.message || 'Failed to save stores.')
|
||||
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: errorMessage
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-add-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-3">
|
||||
<BackButton to="ManageStoresAdmin" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="fw_6 mb-1">Batch Add Stores</h3>
|
||||
<p class="text-muted small mb-0">Create multiple stores at once. Ideal for large-scale onboarding.</p>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div v-for="(store, index) in stores" :key="index" class="col-md-6 col-lg-4">
|
||||
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
|
||||
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
|
||||
:disabled="stores.length <= 1"><i class="fas fa-times-circle"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Store Name *</label>
|
||||
<input v-model="store.name" type="text" class="form-control form-control-sm" placeholder="Store name">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Address *</label>
|
||||
<input v-model="store.address" type="text" class="form-control form-control-sm" placeholder="Complete address">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Description *</label>
|
||||
<input v-model="store.description" type="text" class="form-control form-control-sm" placeholder="Short description">
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Category</label>
|
||||
<select v-model="store.category" class="form-select form-select-sm">
|
||||
<option value="">Select Category</option>
|
||||
<option v-for="cat in categories" :key="cat" :value="cat">{{ cat }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Subcategory</label>
|
||||
<input v-model="store.subcategory" type="text" class="form-control form-control-sm" placeholder="Subcategory">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Owner</label>
|
||||
<select v-model="store.owner_hash" class="form-select form-select-sm">
|
||||
<option value="">System Default</option>
|
||||
<option v-for="owner in owners" :key="owner.hashkey" :value="owner.hashkey">{{ owner.name }} ({{ owner.username }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Another Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3 pt-3 border-top">
|
||||
<button @click="saveStores" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
{{ saving ? 'Saving...' : 'Save All Stores' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-add-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.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); }
|
||||
: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 {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, 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 BackButton from '../Components/Core/BackButton.vue'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
usePageTitle('Batch Add Users')
|
||||
|
||||
const users = ref([
|
||||
{ username: '', name: '', mobile_number: '', password: 'Password123!', type: 'user', parent_hash: '' }
|
||||
])
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const userTypes = ref([])
|
||||
const parents = ref([])
|
||||
|
||||
const addRow = () => {
|
||||
users.value.push({ username: '', name: '', mobile_number: '', password: 'Password123!', type: 'user', parent_hash: '' })
|
||||
}
|
||||
|
||||
const removeRow = (index) => {
|
||||
if (users.value.length > 1) {
|
||||
users.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [typeRes, parentRes] = await Promise.all([
|
||||
axios.post('/admin/list/usertype/create'),
|
||||
axios.post('/admin/user/list/numbers/hash')
|
||||
])
|
||||
|
||||
if (typeRes.data) userTypes.value = typeRes.data
|
||||
if (parentRes.data) parents.value = parentRes.data
|
||||
} catch (err) {
|
||||
console.error('Error fetching user metadata:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const saveUsers = async () => {
|
||||
const invalid = users.value.some(u => !u.username || !u.name || !u.mobile_number || !u.password || !u.type)
|
||||
if (invalid) {
|
||||
modal.open({
|
||||
title: 'Validation Error',
|
||||
body: 'Please fill in all required fields (Username, Name, Mobile, Password, Type) for all rows.'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/batch/users', { users: users.value })
|
||||
if (response.data && response.data.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: `Successfully added ${response.data.count} users.`,
|
||||
onClose: () => navigate({ page: 'UserList' })
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error saving batch users:', err)
|
||||
const errorMessage = err.response?.data?.errors
|
||||
? err.response.data.errors.join('<br>')
|
||||
: (err.response?.data?.message || 'Failed to save users.')
|
||||
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: errorMessage
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="batch-add-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-3">
|
||||
<BackButton to="UserList" />
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h3 class="fw_6 mb-1">Batch Add Users</h3>
|
||||
<p class="text-muted small mb-0">Efficiently register multiple accounts. All passwords default to "Password123!" if not changed.</p>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mb-3">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div v-for="(user, index) in users" :key="index" class="col-md-6 col-lg-4">
|
||||
<div class="leaf-card p-3 bg-white rounded-3 border position-relative h-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">
|
||||
<span class="badge bg-primary rounded-pill">#{{ index + 1 }}</span>
|
||||
<button @click="removeRow(index)" class="btn btn-link text-danger p-0 border-0"
|
||||
:disabled="users.length <= 1"><i class="fas fa-times-circle"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Username *</label>
|
||||
<input v-model="user.username" type="text" class="form-control form-control-sm" placeholder="Unique username">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Full Name *</label>
|
||||
<input v-model="user.name" type="text" class="form-control form-control-sm" placeholder="User's full name">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Mobile *</label>
|
||||
<input v-model="user.mobile_number" type="text" class="form-control form-control-sm" placeholder="09xxxxxxxxx">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Password *</label>
|
||||
<input v-model="user.password" type="text" class="form-control form-control-sm" placeholder="Password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Account Type</label>
|
||||
<select v-model="user.type" class="form-select form-select-sm">
|
||||
<option v-for="type in userTypes" :key="type[0]" :value="type[0]">{{ type[1] }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-bold text-muted mb-1">Parent Account</label>
|
||||
<select v-model="user.parent_hash" class="form-select form-select-sm">
|
||||
<option value="">System Default (Self)</option>
|
||||
<option v-for="p in parents" :key="p.hashkey" :value="p.hashkey">{{ p.name }} ({{ p.username }})</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button @click="addRow" class="btn btn-outline-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-plus-circle me-2"></i> Add Another User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-3 pt-3 border-top">
|
||||
<button @click="saveUsers" :disabled="saving" class="btn btn-primary rounded-pill px-4 fw-semibold">
|
||||
<i class="fas fa-save me-2"></i>
|
||||
{{ saving ? 'Saving...' : 'Save All Users' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.batch-add-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.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); }
|
||||
: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 {
|
||||
background-color: var(--bg-secondary) !important;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -1,323 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Buy View Product Market');
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, required: false },
|
||||
data: { type: Object, default: () => ({}) },
|
||||
payload: { type: Object, default: null }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { role } = useAuth();
|
||||
const modal = useModal();
|
||||
|
||||
import { useProductStore } from '../stores/product';
|
||||
const productStore = useProductStore();
|
||||
|
||||
const product = computed(() => productStore.currentProduct);
|
||||
const loading = computed(() => productStore.loading);
|
||||
const error = computed(() => productStore.error);
|
||||
|
||||
const fetchProductDetails = async () => {
|
||||
const targetHash = props.payload?.product_hashkey || props.payload?.product_hash || props.target;
|
||||
const storeHash = props.payload?.store_hashkey || props.payload?.store_hash || props.data?.store_hash;
|
||||
|
||||
await productStore.fetchProductById(targetHash, storeHash);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const storeHash = props.payload?.store_hash || product.value?.store_hash;
|
||||
if (storeHash) {
|
||||
navigate({ page: 'ViewStoreMarket', props: { target: storeHash } });
|
||||
} else {
|
||||
navigate({ page: 'ListProductsMarket' });
|
||||
}
|
||||
};
|
||||
|
||||
const manageProduct = () => {
|
||||
const storeHash = props.payload?.store_hash || product.value?.store_hash;
|
||||
const productHash = props.payload?.product_hash || props.target;
|
||||
|
||||
if (product.value.is_from_store && storeHash) {
|
||||
navigate({
|
||||
page: 'ManageProductAdmin',
|
||||
props: {
|
||||
payload: {
|
||||
product_hashkey: productHash,
|
||||
store_hashkey: storeHash
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
navigate({ page: 'ManageProductAdmin', props: { target: productHash } });
|
||||
}
|
||||
};
|
||||
|
||||
const displayPrice = computed(() => {
|
||||
if (!product.value) return '';
|
||||
const price = product.value.store_price || product.value.price;
|
||||
const prefix = product.value.is_from_store ? 'Store Price ' : '';
|
||||
return `${prefix}₱${price} / ${product.value.unitname}`;
|
||||
});
|
||||
|
||||
const addToCart = async () => {
|
||||
try {
|
||||
const productHash = props.payload?.product_hash || props.target;
|
||||
const response = await axios.get(`/cart/add/one/${productHash}`);
|
||||
if (response.data === true || response.data?.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: 'Added to cart!'
|
||||
});
|
||||
} else {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to add to cart.'
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Add to cart failed:', e);
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Error adding to cart.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buyNow = () => {
|
||||
// Navigate to a (yet to be created) checkout or confirmation page
|
||||
modal.open({
|
||||
title: 'Info',
|
||||
body: 'Buy Now clicked! This would typically go to checkout.'
|
||||
});
|
||||
};
|
||||
|
||||
const printPosCode = () => {
|
||||
const w = window.open('', '_blank', 'width=400,height=500');
|
||||
w.document.write(`<html><body style="text-align:center;font-family:sans-serif">
|
||||
<h3>${product.value.name}</h3>
|
||||
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(product.value.pos_qrcode)}" />
|
||||
<p style="font-size:12px">${product.value.pos_qrcode}</p>
|
||||
<script>window.onload=()=>{window.print();window.close();}<\/script>
|
||||
</body></html>`);
|
||||
w.document.close();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProductDetails();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-details-page pb-5">
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-3 text-muted">Loading product details...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="tf-container mt-5 text-center">
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
<button @click="goBack" class="btn btn-outline-secondary mt-3 rounded-pill">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else-if="product">
|
||||
<!-- Hero Image Section -->
|
||||
<div class="product-hero">
|
||||
<button @click="goBack" class="back-btn shadow">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="hero-image-container">
|
||||
<FileImage :src="product.photourl && product.photourl.length > 0 ? product.photourl[0] : ''"
|
||||
class="hero-img" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container product-content">
|
||||
<div class="info-card shadow-sm">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 class="fw_7 mb-1">{{ product.name }}</h2>
|
||||
<span class="badge bg-soft-success text-success rounded-pill px-3">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h3 class="price-tag text-primary fw_7 mb-0">{{ displayPrice }}</h3>
|
||||
<small class="text-muted" v-if="product.available !== null">
|
||||
{{ product.available }} available
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description-section mt-4">
|
||||
<h5 class="fw_6 mb-2">Description</h5>
|
||||
<p class="text-muted line-height-16">
|
||||
{{ product.store_description || product.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- POS QR Code Section -->
|
||||
<div v-if="product.pos_qrcode" class="pos-qr-section mt-4 p-3 rounded-xl text-center border">
|
||||
<h6 class="fw_7 mb-2"><i class="fas fa-barcode me-2"></i> POS Scan Code</h6>
|
||||
<div class="qr-container p-2 d-inline-block rounded shadow-sm mb-2 qr-container-bg">
|
||||
<img :src="`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(product.pos_qrcode)}`"
|
||||
:alt="product.pos_qrcode" style="width: 150px; height: 150px;">
|
||||
</div>
|
||||
<div class="small text-muted fw_6 mb-2">{{ product.is_from_store ? 'Store Exclusive Code' : 'Product Identification' }}</div>
|
||||
<div>
|
||||
<button @click="printPosCode" class="btn btn-outline-primary btn-sm rounded-pill px-3">
|
||||
<i class="fas fa-print me-2"></i> Print
|
||||
</button>
|
||||
</div>
|
||||
<div class="stats-row d-flex justify-content-around mt-3 pt-3 border-top">
|
||||
<div class="stat-item">
|
||||
<div class="small text-muted">Sold Today</div>
|
||||
<div class="fw_7 stat-value">
|
||||
{{ product.is_from_store ? (product.store_sold_today ?? product.sold_today ?? 0) : (product.sold_today ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="small text-muted">Total Sold</div>
|
||||
<div class="fw_7 stat-value">
|
||||
{{ product.is_from_store ? (product.store_sold ?? product.sold ?? 0) : (product.sold ?? 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions-grid mt-4 pt-4 border-top">
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<button @click="addToCart"
|
||||
class="btn btn-light w-100 py-3 rounded-xl fw_6 shadow-sm border">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d36eb6a17e27.bin" class="me-2" style="width: 20px;">
|
||||
Add Cart
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<button @click="buyNow" class="btn btn-primary w-100 py-3 rounded-xl fw_6 shadow-sm">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/6446fb001e8b.bin" class="me-2"
|
||||
style="width: 20px; filter: brightness(0) invert(1);">
|
||||
Buy Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="['ult', 'superoperator', 'operator'].includes(role)" class="admin-actions mt-3">
|
||||
<button @click="manageProduct" class="btn btn-soft-dark w-100 py-3 rounded-xl fw_6">
|
||||
<i class="fas fa-cog me-2"></i> Manage Product
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-qr-section {
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.qr-container-bg {
|
||||
background-color: var(--bg-card);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.product-hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-image-container {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-content {
|
||||
margin-top: -30px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: 30px;
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.price-tag {
|
||||
color: #42b983 !important;
|
||||
}
|
||||
|
||||
.bg-soft-success {
|
||||
background: rgba(66, 185, 131, 0.1);
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.btn-soft-dark {
|
||||
background: #f1f2f6;
|
||||
color: #2c3e50;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.line-height-16 {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .info-card {
|
||||
background: #24272c;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .back-btn {
|
||||
background: #24272c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .btn-soft-dark {
|
||||
background: #1a1c20;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,366 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useModal } from '../composables/Core/useModal'
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
|
||||
usePageTitle('My Cart')
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
|
||||
const cart = ref(null)
|
||||
const items = ref([])
|
||||
const isLoading = ref(true)
|
||||
const isUpdating = ref(false)
|
||||
|
||||
const cartTotal = computed(() => {
|
||||
return items.value.reduce((sum, item) => sum + (item.price * item.quantity), 0)
|
||||
})
|
||||
|
||||
const fetchCart = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.post('/cart/get')
|
||||
if (response.data && response.data.success) {
|
||||
cart.value = response.data.cart
|
||||
items.value = response.data.items || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching cart:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateQuantity = async (item, delta) => {
|
||||
const newQuantity = item.quantity + delta
|
||||
if (newQuantity < 1) return
|
||||
|
||||
isUpdating.value = true
|
||||
try {
|
||||
const response = await axios.post('/cart/update', {
|
||||
item_hash: item.hashkey,
|
||||
quantity: newQuantity
|
||||
})
|
||||
if (response.data.success) {
|
||||
item.quantity = newQuantity
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating quantity:', error)
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = async (itemHash) => {
|
||||
modal.yesNoModal({
|
||||
title: 'Remove Item',
|
||||
body: 'Are you sure you want to remove this item from your cart?',
|
||||
onYes: async () => {
|
||||
try {
|
||||
const response = await axios.post('/cart/remove', { item_hash: itemHash })
|
||||
if (response.data.success) {
|
||||
items.value = items.value.filter(i => i.hashkey !== itemHash)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing item:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const clearCart = async () => {
|
||||
modal.yesNoModal({
|
||||
title: 'Clear Cart',
|
||||
body: 'Are you sure you want to clear all items from your cart?',
|
||||
onYes: async () => {
|
||||
try {
|
||||
const response = await axios.post('/cart/clear')
|
||||
if (response.data.success) {
|
||||
items.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing cart:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const checkout = () => {
|
||||
// Navigate to checkout or transaction creation page
|
||||
navigate({ page: 'AddTransaction', props: { scope: 'cart' } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCart()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cart-page pb-5">
|
||||
<div class="tf-container mt-4 mb-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<BackButton />
|
||||
<h2 class="fw_8 ms-3 mb-0 premium-title">Shopping Cart</h2>
|
||||
</div>
|
||||
<button v-if="items.length > 0" @click="clearCart" class="btn-clear text-danger">
|
||||
<i class="far fa-trash-alt me-1"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="tf-container text-center py-5 mt-5">
|
||||
<LoadingSpinner size="large" />
|
||||
<p class="text-muted mt-3">Loading your cart...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="tf-container text-center py-5 mt-5">
|
||||
<div class="empty-cart-illustration mb-4">
|
||||
<i class="fas fa-shopping-basket text-light" style="font-size: 5rem;"></i>
|
||||
</div>
|
||||
<h3 class="fw_7">Your cart is empty</h3>
|
||||
<p class="text-muted">Looks like you haven't added anything to your cart yet.</p>
|
||||
<button @click="navigate({ page: 'MarketProduct' })" class="btn btn-primary rounded-pill px-4 py-2 mt-3 fw_6">
|
||||
Continue Shopping
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tf-container">
|
||||
<div class="cart-items-list mb-4">
|
||||
<div v-for="item in items" :key="item.hashkey" class="cart-item-card animate-fade-in">
|
||||
<div class="item-image-wrapper">
|
||||
<img v-if="item.product?.photourl && item.product.photourl.length > 0"
|
||||
:src="'/RequestData/File/' + item.product.photourl[0]"
|
||||
class="item-img"
|
||||
@error="$event.target.style.display = 'none'">
|
||||
<div v-else class="item-img-placeholder">
|
||||
<i class="fas fa-box"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item-details">
|
||||
<h5 class="fw_7 mb-1">{{ item.product?.name || 'Unknown Product' }}</h5>
|
||||
<p class="text-muted small mb-2">{{ item.product?.category || 'General' }}</p>
|
||||
<div class="item-price fw_7 text-primary">₱{{ item.price.toLocaleString() }}</div>
|
||||
</div>
|
||||
|
||||
<div class="item-actions">
|
||||
<div class="quantity-control shadow-sm rounded-pill">
|
||||
<button @click="updateQuantity(item, -1)" class="btn-qty" :disabled="item.quantity <= 1 || isUpdating">
|
||||
<i class="fas fa-minus"></i>
|
||||
</button>
|
||||
<span class="qty-num fw_7">{{ item.quantity }}</span>
|
||||
<button @click="updateQuantity(item, 1)" class="btn-qty" :disabled="isUpdating">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button @click="removeItem(item.hashkey)" class="btn-remove" :disabled="isUpdating">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary Panel -->
|
||||
<div class="cart-summary-panel glass-card shadow-lg p-4 mb-5">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">Subtotal ({{ items.length }} items)</span>
|
||||
<span class="fw_6">₱{{ cartTotal.toLocaleString() }}</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mb-3 pb-3 border-bottom">
|
||||
<span class="text-muted">Processing Fee</span>
|
||||
<span class="text-success fw_6">FREE</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<span class="fw_8 fs-5">Order Total</span>
|
||||
<span class="fw_8 fs-4 text-primary">₱{{ cartTotal.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<button @click="checkout" class="btn btn-primary w-100 py-3 rounded-xl fw_8 fs-5 glow-button">
|
||||
Proceed to Checkout <i class="fas fa-arrow-right ms-2 opacity-50"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.premium-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
color: #1a202c;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
color: #f7fafc;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.cart-items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cart-item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
border: 1px solid rgba(0,0,0,0.03);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .cart-item-card {
|
||||
background: #2d3748;
|
||||
border-color: rgba(255,255,255,0.05);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.cart-item-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.item-image-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #f7fafc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .item-image-wrapper {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-img-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #cbd5e0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.item-details {
|
||||
flex-grow: 1;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quantity-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .quantity-control {
|
||||
background: #1a202c;
|
||||
}
|
||||
|
||||
.btn-qty {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: white;
|
||||
color: #4a5568;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .btn-qty {
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.btn-qty:hover:not(:disabled) {
|
||||
background: #edf2f7;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.qty-num {
|
||||
width: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: #fff5f5;
|
||||
color: #f56565;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .btn-remove {
|
||||
background: rgba(245, 101, 101, 0.1);
|
||||
}
|
||||
|
||||
.cart-summary-panel {
|
||||
border-radius: 24px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .cart-summary-panel {
|
||||
background: #2d3748;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.glow-button {
|
||||
box-shadow: 0 4px 14px 0 rgba(70, 107, 255, 0.39);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.glow-button:hover {
|
||||
box-shadow: 0 6px 20px rgba(70, 107, 255, 0.45);
|
||||
background-color: #3b82f6;
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { useChapters } from '../composables/useChapters.js';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
|
||||
usePageTitle('Search Members');
|
||||
|
||||
const { searchMembers, loading } = useChapters();
|
||||
|
||||
const query = ref('');
|
||||
const results = ref([]);
|
||||
const searched = ref(false);
|
||||
let debounceTimer = null;
|
||||
|
||||
const roleLabel = (role) => {
|
||||
const map = {
|
||||
PRESIDENT: 'President',
|
||||
VICE_PRESIDENT: 'Vice President',
|
||||
SECRETARY: 'Secretary',
|
||||
TREASURER: 'Treasurer',
|
||||
AUDITOR: 'Auditor',
|
||||
BOARD_MEMBER: 'Board Member',
|
||||
};
|
||||
return map[role] || role;
|
||||
};
|
||||
|
||||
const isOfficer = (role) => role && role !== 'MEMBER';
|
||||
|
||||
const runSearch = async () => {
|
||||
const q = query.value.trim();
|
||||
if (q.length < 2) {
|
||||
results.value = [];
|
||||
searched.value = false;
|
||||
return;
|
||||
}
|
||||
searched.value = true;
|
||||
results.value = await searchMembers(q);
|
||||
};
|
||||
|
||||
watch(query, () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(runSearch, 400);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container py-4" style="max-width: 640px;">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-search me-2"></i>Search Members</h5>
|
||||
|
||||
<div class="search-bar rounded-pill p-1 mb-3 d-flex align-items-center">
|
||||
<i class="fas fa-search mx-3 text-muted"></i>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="form-control border-0 bg-transparent"
|
||||
placeholder="Type a member name..."
|
||||
style="box-shadow: none;"
|
||||
/>
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-3 text-muted"></span>
|
||||
</div>
|
||||
|
||||
<div v-if="query.trim().length < 2" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-keyboard fa-2x opacity-25 mb-2"></i>
|
||||
<p class="small mb-0">Type at least 2 characters to search.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!results.length && searched && !loading" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-user-slash fa-2x opacity-25 mb-2"></i>
|
||||
<p class="small mb-0">No members found matching "{{ query }}".</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(m, i) in results" :key="i" class="result-card rounded-4 p-3 mb-2 d-flex align-items-center gap-3">
|
||||
<div class="avatar rounded-circle d-flex align-items-center justify-content-center fw-bold">
|
||||
{{ (m.name || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<div class="fw-semibold text-truncate">{{ m.name }}</div>
|
||||
<div class="small text-muted text-truncate">{{ m.chapter_name }}</div>
|
||||
</div>
|
||||
<span v-if="isOfficer(m.role)" class="badge rounded-pill role-badge">{{ roleLabel(m.role) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.result-card {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.role-badge {
|
||||
background: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
:global(.dark-mode) .search-bar,
|
||||
:global(.dark-mode) .result-card {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,238 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import { encodeHash } from '../composables/useUrlEncoder';
|
||||
import { computed } from 'vue';
|
||||
import SearchableTableWrapper from '../Components/Core/SearchableTableWrapper.vue';
|
||||
import GovernanceResolutions from './Fragments/GovernanceResolutions.vue';
|
||||
import DocumentRepository from './Fragments/DocumentRepository.vue';
|
||||
|
||||
const props = defineProps({
|
||||
target: String
|
||||
});
|
||||
|
||||
usePageTitle('Cooperative Details');
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const cooperative = ref(null);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const tableDensity = ref('comfortable');
|
||||
const activeTab = ref('members');
|
||||
|
||||
const fetchDetails = async () => {
|
||||
if (!props.target) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/Get', { hashkey: props.target });
|
||||
if (response.data.success) {
|
||||
cooperative.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cooperative details');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const viewMember = (userHashkey) => {
|
||||
navigate({ page: 'UserInfoEdit', props: { target: userHashkey } });
|
||||
};
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!cooperative.value?.members) return [];
|
||||
if (!searchQuery.value) return cooperative.value.members;
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
return cooperative.value.members.filter(m =>
|
||||
(m.user?.fullname?.toLowerCase().includes(query)) ||
|
||||
(m.user?.name?.toLowerCase().includes(query)) ||
|
||||
(m.role?.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
const shareRegisterLink = async () => {
|
||||
const encodedHash = encodeHash(props.target);
|
||||
const url = `${window.location.origin}/register-coop--${encodedHash}`;
|
||||
const title = cooperative.value?.name ?? 'Join our Cooperative';
|
||||
const text = `Register as a member of ${title}`;
|
||||
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, text, url });
|
||||
} catch {
|
||||
// user cancelled or share failed — silently ignore
|
||||
}
|
||||
} else {
|
||||
await navigator.clipboard.writeText(url);
|
||||
modal.show({ title: 'Link Copied', message: 'Registration link copied to clipboard.', variant: 'info' });
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchDetails);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cooperative-detail pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-primary mb-2"></i>
|
||||
<p class="text-muted">Loading details...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!cooperative" class="text-center py-5">
|
||||
<p class="text-danger">Cooperative not found.</p>
|
||||
<button @click="navigate({ page: 'CooperativeList' })" class="btn btn-primary rounded-pill px-4 mt-3">Back to List</button>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- coop Header -->
|
||||
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4 bg-primary text-white overflow-hidden position-relative">
|
||||
<div class="position-absolute top-0 end-0 opacity-10 p-4">
|
||||
<i class="fas fa-landmark fa-6x rotate-15"></i>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3 position-relative">
|
||||
<div class="bg-white text-primary rounded-circle p-3 shadow-lg">
|
||||
<i class="fas fa-users fa-2x"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="fw_8 mb-1">{{ cooperative.name }}</h2>
|
||||
<p class="mb-0 opacity-75"><i class="fas fa-map-marker-alt me-1"></i> {{ cooperative.address || 'No address provided' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="card border-0 shadow-sm rounded-20 mb-4 overflow-hidden">
|
||||
<div class="d-flex border-bottom bg-light">
|
||||
<button
|
||||
v-for="tab in ['members', 'governance', 'documents']"
|
||||
:key="tab"
|
||||
@click="activeTab = tab"
|
||||
:class="['flex-fill py-3 border-0 transition-all fw_7 text-capitalize',
|
||||
activeTab === tab ? 'bg-white text-primary border-bottom-primary' : 'bg-transparent text-muted']"
|
||||
>
|
||||
<i :class="['me-2',
|
||||
tab === 'members' ? 'fas fa-user-friends' :
|
||||
tab === 'governance' ? 'fas fa-gavel' : 'fas fa-folder-open']">
|
||||
</i>
|
||||
{{ tab }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons (Conditional) -->
|
||||
<div v-if="activeTab === 'members'" class="mb-4 d-flex justify-content-end gap-2 flex-wrap animate-fade-in">
|
||||
<button v-if="!cooperative.is_member"
|
||||
@click="navigate({ page: 'CooperativeMemberRegister', props: { target: props.target } })"
|
||||
class="btn btn-success rounded-pill px-4 py-2 shadow-sm">
|
||||
<i class="fas fa-id-card me-2"></i> Register as Member
|
||||
</button>
|
||||
<button @click="shareRegisterLink" class="btn btn-outline-primary rounded-pill px-4 py-2 shadow-sm">
|
||||
<i class="fas fa-share-alt me-2"></i> Share Register Link
|
||||
</button>
|
||||
<button @click="navigate({ page: 'EnrollFarmer', props: { target: props.target } })" class="btn btn-primary rounded-pill px-4 py-2 shadow-sm">
|
||||
<i class="fas fa-user-plus me-2"></i> Enroll New Farmer
|
||||
</button>
|
||||
<button @click="navigate({ page: 'BatchAddCooperativeMembers', props: { target: props.target } })" class="btn btn-primary rounded-pill px-4 py-2 shadow-sm">
|
||||
<i class="fas fa-users-cog me-2"></i> Batch Add Members
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<transition name="fade" mode="out-in">
|
||||
<div :key="activeTab">
|
||||
<!-- Members Section -->
|
||||
<div v-if="activeTab === 'members'" class="animate-fade-in">
|
||||
<h4 class="fw_6 mb-3 text-dark d-flex align-items-center gap-2">
|
||||
<i class="fas fa-users text-primary opacity-50"></i>
|
||||
Members ({{ filteredMembers.length }}{{ searchQuery ? ' found' : '' }})
|
||||
</h4>
|
||||
|
||||
<SearchableTableWrapper
|
||||
v-model:search-value="searchQuery"
|
||||
v-model:density-value="tableDensity"
|
||||
:empty="filteredMembers.length === 0"
|
||||
empty-title="No members found"
|
||||
empty-message="Try a different search term or enroll new members."
|
||||
empty-icon="fas fa-user-friends"
|
||||
>
|
||||
<template #table>
|
||||
<thead>
|
||||
<tr class="bg-light">
|
||||
<th class="border-0">Member Name</th>
|
||||
<th class="border-0">Role</th>
|
||||
<th class="border-0 text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="membership in filteredMembers" :key="membership.hashkey"
|
||||
class="cursor-pointer"
|
||||
@click="viewMember(membership.user.hashkey)">
|
||||
<td class="border-0">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bg-soft-primary text-primary rounded-circle p-2 member-avatar">
|
||||
<i class="fas fa-user small"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="fw_6 mb-0 text-dark">{{ membership.user.fullname || membership.user.name }}</h6>
|
||||
<small class="text-muted d-block opacity-75">ID: {{ membership.user.hashkey?.substring(0, 8) }}...</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-0">
|
||||
<span class="badge rounded-pill bg-soft-info text-info px-3 py-2 fw_5">
|
||||
{{ membership.role }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-0 text-end">
|
||||
<button class="btn btn-icon btn-soft-primary rounded-circle shadow-sm border-0">
|
||||
<i class="fas fa-chevron-right small"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</template>
|
||||
</SearchableTableWrapper>
|
||||
</div>
|
||||
|
||||
<!-- Governance Section -->
|
||||
<div v-else-if="activeTab === 'governance'" class="animate-fade-in">
|
||||
<GovernanceResolutions :org-hash="props.target" />
|
||||
</div>
|
||||
|
||||
<!-- Documents Section -->
|
||||
<div v-else-if="activeTab === 'documents'" class="animate-fade-in">
|
||||
<DocumentRepository :org-hash="props.target" />
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.hover-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
|
||||
.bg-soft-info { background-color: rgba(0, 184, 217, 0.1); }
|
||||
.text-info { color: #00B8D9 !important; }
|
||||
.member-avatar { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
|
||||
.btn-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); color: var(--primary); }
|
||||
|
||||
.border-bottom-primary { border-bottom: 3px solid var(--primary) !important; }
|
||||
.transition-all { transition: all 0.2s ease; }
|
||||
.rotate-15 { transform: rotate(-15deg); }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -1,72 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAuth } from '../composables/Core/useAuth.js';
|
||||
import CooperativeDetail from '@/Pages/CooperativeDetail.vue';
|
||||
import DocumentRepository from '@/Pages/Fragments/DocumentRepository.vue';
|
||||
import GovernanceResolutions from '@/Pages/Fragments/GovernanceResolutions.vue';
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const activeOrgHash = computed(() => {
|
||||
const coops = user.value?.settings?.cooperatives;
|
||||
if (Array.isArray(coops) && coops.length > 0) return coops[0];
|
||||
return null;
|
||||
});
|
||||
|
||||
const hubTab = ref('overview');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cooperative-hub-page pb-5">
|
||||
<div class="tf-container mt-3">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="fw_7 mb-0 d-flex align-items-center gap-2">
|
||||
<i class="fas fa-landmark text-primary opacity-50"></i>
|
||||
Cooperative Hub
|
||||
</h5>
|
||||
<div class="d-flex gap-1 bg-soft-primary p-1 rounded-pill">
|
||||
<button
|
||||
@click="hubTab = 'overview'"
|
||||
:class="['btn btn-xs rounded-pill px-3', hubTab === 'overview' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
|
||||
>Overview</button>
|
||||
<button
|
||||
@click="hubTab = 'docs'"
|
||||
:class="['btn btn-xs rounded-pill px-3', hubTab === 'docs' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
|
||||
>Docs</button>
|
||||
<button
|
||||
@click="hubTab = 'votes'"
|
||||
:class="['btn btn-xs rounded-pill px-3', hubTab === 'votes' ? 'btn-primary shadow-sm' : 'btn-transparent text-primary small']"
|
||||
>Resolutions</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!activeOrgHash" class="alert alert-warning small">
|
||||
No active cooperative is configured for your account.
|
||||
</div>
|
||||
|
||||
<div v-else class="card border-0 shadow-sm rounded-20 bg-white overflow-hidden p-0">
|
||||
<div v-if="hubTab === 'overview'">
|
||||
<CooperativeDetail :target="activeOrgHash" />
|
||||
</div>
|
||||
<div v-else-if="hubTab === 'docs'" class="p-3">
|
||||
<DocumentRepository :org-hash="activeOrgHash" />
|
||||
</div>
|
||||
<div v-else-if="hubTab === 'votes'" class="p-3">
|
||||
<GovernanceResolutions :org-hash="activeOrgHash" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn-xs {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
border-radius: 50rem;
|
||||
}
|
||||
.bg-soft-primary {
|
||||
background-color: rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,216 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import TransactionListSkeleton from '../Components/Core/Skeleton/TransactionListSkeleton.vue';
|
||||
|
||||
usePageTitle('Cooperatives');
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
const { isUltimate, isSuperOperator } = useAuth();
|
||||
|
||||
const cooperatives = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const showCreateModal = ref(false);
|
||||
const isSaving = ref(false);
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
address: ''
|
||||
});
|
||||
|
||||
const fetchCooperatives = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/List');
|
||||
if (response.data.success) {
|
||||
cooperatives.value = response.data.data;
|
||||
} else {
|
||||
error.value = response.data.message || 'Failed to load cooperatives.';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cooperatives:', err);
|
||||
error.value = err.response?.data?.message || 'A server error occurred. Please try again later.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
createForm.value = { name: '', address: '' };
|
||||
showCreateModal.value = true;
|
||||
};
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false;
|
||||
createForm.value = { name: '', address: '' };
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!createForm.value.name.trim()) {
|
||||
modal.open({ title: 'Error', body: 'Cooperative name is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/Create', createForm.value);
|
||||
if (response.data.success) {
|
||||
showCreateModal.value = false;
|
||||
createForm.value = { name: '', address: '' };
|
||||
await fetchCooperatives();
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: 'Cooperative created successfully!',
|
||||
});
|
||||
} else {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: response.data.message || 'Failed to create cooperative. Please try again.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create cooperative:', error);
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: error.response?.data?.message || 'Failed to create cooperative. Please try again.'
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const viewDetails = (hashkey) => {
|
||||
navigate({ page: 'CooperativeDetail', props: { target: hashkey } });
|
||||
};
|
||||
|
||||
const goToCreate = () => {
|
||||
navigate({ page: 'CreateCooperative' });
|
||||
};
|
||||
|
||||
onMounted(fetchCooperatives);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cooperative-list pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-4">
|
||||
<h3 class="fw_6 mb-3">Cooperatives</h3>
|
||||
<div class="d-flex gap-2">
|
||||
<button v-if="isUltimate || isSuperOperator" @click="navigate({ page: 'BatchAddCooperatives' })" class="btn btn-outline-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 flex-grow-1 justify-content-center">
|
||||
<i class="fas fa-layer-group"></i> Batch Add
|
||||
</button>
|
||||
<button @click="goToCreate" class="btn btn-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 flex-grow-1 justify-content-center">
|
||||
<i class="fas fa-plus"></i> New Cooperative
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<TransactionListSkeleton :count="6" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="text-center py-5 px-4 animate-fade-in">
|
||||
<div class="bg-soft-danger text-danger rounded-circle mx-auto mb-4 p-3 d-flex align-items-center justify-content-center" style="width: 80px; height: 80px;">
|
||||
<i class="fas fa-exclamation-triangle fa-3x"></i>
|
||||
</div>
|
||||
<h4 class="fw-bold mb-2 text-dark">Data Not Loaded</h4>
|
||||
<p class="text-muted mb-4 px-lg-5">{{ error }}</p>
|
||||
<button @click="fetchCooperatives" class="btn btn-outline-primary rounded-pill px-4">
|
||||
<i class="fas fa-sync-alt me-2"></i> Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="cooperatives.length === 0" class="text-center py-5">
|
||||
<i class="fas fa-users-slash fa-3x text-muted opacity-2 mb-3"></i>
|
||||
<p class="text-muted">No cooperatives found.</p>
|
||||
<button @click="goToCreate" class="btn btn-primary mt-3 rounded-pill px-4">
|
||||
<i class="fas fa-plus me-2"></i> Create First Cooperative
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="row g-3">
|
||||
<div v-for="coop in cooperatives" :key="coop.hashkey" class="col-12 col-md-6">
|
||||
<div @click="viewDetails(coop.hashkey)" class="card border-0 shadow-sm rounded-20 p-3 cursor-pointer hover-card">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div class="bg-primary-subtle rounded-circle p-3 text-primary">
|
||||
<i class="fas fa-users fa-lg"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="fw_6 mb-1 text-truncate">{{ coop.name }}</h5>
|
||||
<p class="text-muted small mb-0"><i class="fas fa-map-marker-alt me-1"></i> {{ coop.address || 'No address' }}</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-light text-dark rounded-pill border">{{ coop.members_count || 0 }} Members</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Cooperative Modal -->
|
||||
<div v-if="showCreateModal" class="modal-backdrop-custom" @click.self="closeCreateModal">
|
||||
<div class="modal-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw_6 mb-0">Create Cooperative</h5>
|
||||
<button @click="closeCreateModal" class="btn-close"></button>
|
||||
</div>
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Cooperative Name *</label>
|
||||
<input v-model="createForm.name" type="text" class="form-control rounded-pill" required placeholder="Enter cooperative name">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">Address</label>
|
||||
<textarea v-model="createForm.address" class="form-control rounded-15" rows="2" placeholder="Enter address (optional)"></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" @click="closeCreateModal" class="btn btn-light rounded-pill flex-grow-1 py-2">Cancel</button>
|
||||
<button :disabled="isSaving" type="submit" class="btn btn-primary rounded-pill flex-grow-1 py-2">
|
||||
<span v-if="isSaving"><i class="fas fa-spinner fa-spin me-1"></i> Creating...</span>
|
||||
<span v-else>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.rounded-15 { border-radius: 15px; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.hover-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
|
||||
.bg-primary-subtle { background-color: rgba(13, 110, 253, 0.1); }
|
||||
.modal-backdrop-custom {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1050;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-card {
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,493 +0,0 @@
|
||||
<template>
|
||||
<div class="animate-fade-in pb-5">
|
||||
<!-- Header -->
|
||||
<div class="tf-container mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<button @click="navigate({ page: 'CooperativeDetail', props: { target: target } })"
|
||||
class="btn btn-link text-decoration-none p-0 d-flex align-items-center text-muted">
|
||||
<i class="fas fa-arrow-left me-2"></i>
|
||||
<span>Back to Cooperative</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingCoop" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="cooperative" class="coop-header-card p-4 rounded-20 shadow-sm mb-4 position-relative overflow-hidden">
|
||||
<div class="header-overlay"></div>
|
||||
<div class="position-relative z-1">
|
||||
<div class="badge bg-white text-primary rounded-pill px-3 py-1 mb-2 mb-md-3 smallest fw-bold shadow-sm">
|
||||
{{ cooperative.cooperative_type || 'COOPERATIVE' }}
|
||||
</div>
|
||||
<h1 class="h2 fw-black text-white mb-2">{{ cooperative.name }}</h1>
|
||||
<div class="d-flex flex-wrap gap-3 text-white-50 small">
|
||||
<div v-if="cooperative.address" class="d-flex align-items-center">
|
||||
<i class="fas fa-map-marker-alt me-2"></i>
|
||||
{{ cooperative.address }}
|
||||
</div>
|
||||
<div v-if="cooperative.registration_number" class="d-flex align-items-center">
|
||||
<i class="fas fa-id-card me-2"></i>
|
||||
Reg: {{ cooperative.registration_number }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registration Form -->
|
||||
<div class="tf-container">
|
||||
<div v-if="alreadyMember" class="alert alert-info rounded-15 border-0 shadow-sm mb-4 d-flex align-items-center p-3 animate-fade-in">
|
||||
<div class="icon-circle bg-info text-white me-3 p-2 rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0 fw-bold">You are already a member</h6>
|
||||
<p class="mb-0 small opacity-75">You have already registered as a member of this cooperative.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardSimple title="Membership Information" icon="fas fa-user-tag" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Membership Type</label>
|
||||
<select v-model="form.membership_type" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">Select Type</option>
|
||||
<option value="REGULAR">REGULAR</option>
|
||||
<option value="ASSOCIATE">ASSOCIATE</option>
|
||||
<option value="HONORARY">HONORARY</option>
|
||||
<option value="LABORATORY">LABORATORY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Membership Level</label>
|
||||
<select v-model="form.membership_level" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">Select Level</option>
|
||||
<option value="PRIMARY">PRIMARY</option>
|
||||
<option value="SECONDARY">SECONDARY</option>
|
||||
<option value="TERTIARY">TERTIARY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Year Membership Began</label>
|
||||
<input type="number" v-model="form.year_beginning" class="form-control premium-input"
|
||||
placeholder="e.g. 2024" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Position Details (Optional)" icon="fas fa-briefcase" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Officer Position</label>
|
||||
<input type="text" v-model="form.officer_position" class="form-control premium-input"
|
||||
placeholder="e.g. Board Member" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Officer Level</label>
|
||||
<select v-model="form.officer_level" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">Select Level</option>
|
||||
<option value="PRIMARY">PRIMARY</option>
|
||||
<option value="SECONDARY">SECONDARY</option>
|
||||
<option value="TERTIARY">TERTIARY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Concurrent Position</label>
|
||||
<input type="text" v-model="form.concurrent_position" class="form-control premium-input"
|
||||
placeholder="e.g. Treasurer" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Concurrent Level</label>
|
||||
<select v-model="form.concurrent_level" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">Select Level</option>
|
||||
<option value="PRIMARY">PRIMARY</option>
|
||||
<option value="SECONDARY">SECONDARY</option>
|
||||
<option value="TERTIARY">TERTIARY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Cooperative Position</label>
|
||||
<input type="text" v-model="form.cooperative_position" class="form-control premium-input"
|
||||
placeholder="e.g. Chairperson" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Classification" icon="fas fa-tags" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Priority Sector <span class="fw-normal">(select all that apply)</span></label>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-6 col-md-4" v-for="s in prioritySectors" :key="s">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'ps-reg-' + s" :value="s" v-model="form.priority_sector" :disabled="alreadyMember">
|
||||
<label class="form-check-label small" :for="'ps-reg-' + s">{{ s }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Common Bond</label>
|
||||
<select v-model="form.common_bond" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="b in commonBonds" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold text-muted d-block mb-2">Vulnerability Classification</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-4" v-for="opt in vulnerabilityOptions" :key="opt">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'vuln-'+opt"
|
||||
:value="opt" v-model="form.vulnerability_classifications" :disabled="alreadyMember" />
|
||||
<label class="form-check-label small" :for="'vuln-'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Government Program Participation" icon="fas fa-list-check" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 col-md-4" v-for="prog in programOptions" :key="prog">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'prog-'+prog"
|
||||
:value="prog" v-model="form.program_participation" :disabled="alreadyMember" />
|
||||
<label class="form-check-label small" :for="'prog-'+prog">{{ prog }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLP -->
|
||||
<template v-if="form.program_participation.includes('SLP')">
|
||||
<hr class="my-3" />
|
||||
<p class="small fw-bold text-success mb-2"><i class="fas fa-seedling me-1"></i>SLP Details</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">SLP Track</label>
|
||||
<select v-model="form.slp_track" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">— Select —</option>
|
||||
<option value="MD">Microenterprise Development (MD)</option>
|
||||
<option value="EF">Employment Facilitation (EF)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">SLPA / Association Name</label>
|
||||
<input type="text" v-model="form.slp_association_name" class="form-control premium-input" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">Listahanan (NHTO) ID</label>
|
||||
<input type="text" v-model="form.listahanan_id" class="form-control premium-input" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">4Ps Household ID</label>
|
||||
<input type="text" v-model="form.fourtps_household_id" class="form-control premium-input" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TUPAD -->
|
||||
<template v-if="form.program_participation.includes('TUPAD')">
|
||||
<hr class="my-3" />
|
||||
<p class="small fw-bold text-warning mb-2"><i class="fas fa-hard-hat me-1"></i>TUPAD Details</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold text-muted">Beneficiary Category</label>
|
||||
<select v-model="form.tupad_category" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="c in tupadCategories" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">Insurance Beneficiary Name</label>
|
||||
<input type="text" v-model="form.tupad_insurance_beneficiary_name" class="form-control premium-input" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">Relationship</label>
|
||||
<input type="text" v-model="form.tupad_insurance_beneficiary_relation" class="form-control premium-input" placeholder="e.g. Spouse, Child" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- OSEC/NSRP -->
|
||||
<template v-if="form.program_participation.includes('OSEC/NSRP')">
|
||||
<hr class="my-3" />
|
||||
<p class="small fw-bold text-primary mb-2"><i class="fas fa-briefcase me-1"></i>OSEC / NSRP Employment Profile</p>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">Employment Status</label>
|
||||
<select v-model="form.employment_status" class="form-select premium-select" :disabled="alreadyMember">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="s in employmentStatuses" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold text-muted">Preferred Occupation</label>
|
||||
<input type="text" v-model="form.preferred_occupation" class="form-control premium-input" placeholder="e.g. Farmer, Welder" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold text-muted d-block mb-2">Technical Skills</label>
|
||||
<div v-if="!alreadyMember" class="d-flex gap-2 mb-2">
|
||||
<input v-model="newSkill" type="text" class="form-control premium-input"
|
||||
placeholder="Add a skill and press +" @keyup.enter="addSkill" />
|
||||
<button @click="addSkill" class="btn btn-outline-primary rounded-pill px-3">+</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<span v-for="(sk, i) in form.nsrp_skills" :key="i"
|
||||
class="badge bg-primary-subtle text-primary rounded-pill px-3 py-2">
|
||||
{{ sk }}
|
||||
<i v-if="!alreadyMember" class="fas fa-times ms-1 cursor-pointer" @click="form.nsrp_skills.splice(i, 1)"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Government ID Numbers" icon="fas fa-id-badge" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold text-muted">PhilSys ID</label>
|
||||
<input type="text" v-model="form.philsys_id" class="form-control premium-input" placeholder="National ID" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold text-muted">SSS Number</label>
|
||||
<input type="text" v-model="form.sss_number" class="form-control premium-input" placeholder="00-0000000-0" :disabled="alreadyMember" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold text-muted">Pag-IBIG Number</label>
|
||||
<input type="text" v-model="form.pagibig_number" class="form-control premium-input" placeholder="0000-0000-0000" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Other Information" icon="fas fa-ellipsis-h" class="mb-4 rounded-20 border-0 shadow-sm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold text-muted">Alternative Cooperative Name</label>
|
||||
<input type="text" v-model="form.cooperative_name_alt" class="form-control premium-input"
|
||||
placeholder="If the cooperative is known by another name" :disabled="alreadyMember" />
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="px-3 px-md-0 mt-5">
|
||||
<button
|
||||
@click="handleRegister"
|
||||
class="btn btn-premium-launch w-100 py-3 rounded-pill shadow-primary-sm d-flex align-items-center justify-content-center gap-2"
|
||||
:disabled="isSaving || alreadyMember"
|
||||
>
|
||||
<span v-if="isSaving" class="spinner-border spinner-border-sm" role="status"></span>
|
||||
<i v-else class="fas fa-check-circle"></i>
|
||||
<span class="fw-black">{{ alreadyMember ? 'Already Registered' : 'Confirm Registration' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
|
||||
const props = defineProps({
|
||||
target: String
|
||||
});
|
||||
|
||||
usePageTitle('Register as Member');
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const cooperative = ref(null);
|
||||
const loadingCoop = ref(true);
|
||||
const isSaving = ref(false);
|
||||
const alreadyMember = ref(false);
|
||||
const prioritySectors = ref([]);
|
||||
|
||||
const commonBonds = ['Residential', 'Institutional', 'Occupational', 'Associational'];
|
||||
const employmentStatuses = ['Employed', 'Underemployed', 'Unemployed', 'Self-employed'];
|
||||
const tupadCategories = ['Underemployed', 'Displaced Worker', 'Senior Citizen (fit to work)', 'PWD', 'Solo Parent', 'Indigenous Person', 'Former Rebel'];
|
||||
const vulnerabilityOptions = ['Indigenous People (IP)', 'Person with Disability (PWD)', 'Senior Citizen', 'Solo Parent', 'Out-of-School Youth (OSY)', 'Internally Displaced Person (IDP)', 'Distressed OFW', 'Former Rebel'];
|
||||
const programOptions = ['SLP', 'TUPAD', 'OSEC/NSRP', '4Ps/Pantawid Pamilya', 'Listahanan'];
|
||||
const newSkill = ref('');
|
||||
const addSkill = () => {
|
||||
const s = newSkill.value.trim();
|
||||
if (s && !form.value.nsrp_skills.includes(s)) form.value.nsrp_skills.push(s);
|
||||
newSkill.value = '';
|
||||
};
|
||||
|
||||
const form = ref({
|
||||
membership_type: '',
|
||||
membership_level: '',
|
||||
officer_position: '',
|
||||
officer_level: '',
|
||||
concurrent_position: '',
|
||||
concurrent_level: '',
|
||||
cooperative_name_alt: '',
|
||||
cooperative_position: '',
|
||||
year_beginning: '',
|
||||
// Classification
|
||||
priority_sector: [],
|
||||
common_bond: '',
|
||||
vulnerability_classifications: [],
|
||||
// Gov IDs
|
||||
philsys_id: '',
|
||||
sss_number: '',
|
||||
pagibig_number: '',
|
||||
// SLP
|
||||
slp_track: '',
|
||||
slp_association_name: '',
|
||||
listahanan_id: '',
|
||||
fourtps_household_id: '',
|
||||
// TUPAD
|
||||
tupad_category: '',
|
||||
tupad_insurance_beneficiary_name: '',
|
||||
tupad_insurance_beneficiary_relation: '',
|
||||
// OSEC/NSRP
|
||||
preferred_occupation: '',
|
||||
nsrp_skills: [],
|
||||
employment_status: '',
|
||||
// Programs
|
||||
program_participation: [],
|
||||
});
|
||||
|
||||
const fetchCooperative = async () => {
|
||||
loadingCoop.value = true;
|
||||
try {
|
||||
const [coopRes, settingsRes] = await Promise.all([
|
||||
axios.post('/Cooperatives/Get', { hashkey: props.target }),
|
||||
axios.get('/api/public/system-settings'),
|
||||
]);
|
||||
if (coopRes.data.success) {
|
||||
cooperative.value = coopRes.data.data;
|
||||
alreadyMember.value = coopRes.data.is_member;
|
||||
|
||||
if (alreadyMember.value && coopRes.data.membership) {
|
||||
const m = coopRes.data.membership;
|
||||
Object.keys(form.value).forEach(k => {
|
||||
if (m[k] !== undefined && m[k] !== null) form.value[k] = m[k];
|
||||
});
|
||||
}
|
||||
}
|
||||
if (settingsRes.data?.priority_sectors) {
|
||||
prioritySectors.value = settingsRes.data.priority_sectors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CoopRegister] Failed to fetch cooperative:', error);
|
||||
} finally {
|
||||
loadingCoop.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!form.value.membership_type) {
|
||||
modal.open({ title: 'Missing Info', body: 'Please select a membership type.' });
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/Member/Register', {
|
||||
cooperative_hash: props.target,
|
||||
...form.value,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
modal.open({
|
||||
title: 'Registration Successful',
|
||||
body: 'You have been registered as a member of ' + cooperative.value.name,
|
||||
onClose: () => navigate({ page: 'CooperativeDetail', props: { target: props.target } })
|
||||
});
|
||||
} else {
|
||||
modal.open({
|
||||
title: 'Registration Failed',
|
||||
body: response.data.message || 'Something went wrong.'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: error.response?.data?.message || 'Failed to complete registration. Please try again later.'
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchCooperative();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coop-header-card {
|
||||
background: linear-gradient(135deg, var(--accent-color), #2d3436);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: url('https://www.transparenttextures.com/patterns/carbon-fibre.png');
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
.premium-input, .premium-select {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--bg-secondary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.premium-input:focus, .premium-select:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, var(--accent-color), #4834d4);
|
||||
color: white;
|
||||
border: none;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(83, 61, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.rounded-20 {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rounded-15 {
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input,
|
||||
:global(.dark-mode) .premium-select {
|
||||
background-color: var(--bg-card);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input:focus,
|
||||
:global(.dark-mode) .premium-select:focus {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useChapters } from '../composables/useChapters.js';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
|
||||
usePageTitle('Create Member');
|
||||
|
||||
const { fetchOfficerScope, loading } = useChapters();
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const ownChapter = ref(null);
|
||||
const cooperative = ref(null);
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
username: '',
|
||||
mobile_number: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const fieldErrors = ref({});
|
||||
const errorMessage = ref('');
|
||||
const submitting = ref(false);
|
||||
const done = ref(false);
|
||||
|
||||
const mobileError = ref('');
|
||||
const mobileTaken = ref(false);
|
||||
const usernameTaken = ref(false);
|
||||
|
||||
const validateMobile = (val) => {
|
||||
if (!val) { mobileError.value = 'Mobile number is required.'; return false; }
|
||||
if (!/^(09|\+639)\d{9}$/.test(val)) {
|
||||
mobileError.value = 'Must be a valid Philippine mobile number (e.g. 09XXXXXXXXX).';
|
||||
return false;
|
||||
}
|
||||
mobileError.value = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
const checkMobile = async () => {
|
||||
if (!validateMobile(form.value.mobile_number)) return;
|
||||
try {
|
||||
const res = await axios.post('/admin/user/number/exists', { mobile_number: form.value.mobile_number });
|
||||
mobileTaken.value = !!res.data?.exists;
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const checkUsername = async () => {
|
||||
if (!form.value.username) { usernameTaken.value = false; return; }
|
||||
try {
|
||||
const res = await axios.post('/admin/user/username/exists', { username: form.value.username });
|
||||
usernameTaken.value = !!res.data?.exists;
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
form.value.name &&
|
||||
form.value.username &&
|
||||
form.value.mobile_number &&
|
||||
form.value.password &&
|
||||
!mobileError.value &&
|
||||
!mobileTaken.value &&
|
||||
!usernameTaken.value &&
|
||||
ownChapter.value?.hashkey
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting.value || !canSubmit.value) return;
|
||||
fieldErrors.value = {};
|
||||
errorMessage.value = '';
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/public/chapter/register', {
|
||||
chapter_hash: ownChapter.value.hashkey,
|
||||
name: form.value.name,
|
||||
username: form.value.username,
|
||||
mobile_number: form.value.mobile_number,
|
||||
password: form.value.password,
|
||||
});
|
||||
if (res.data.success) {
|
||||
done.value = true;
|
||||
} else {
|
||||
errorMessage.value = res.data.message || 'Failed to create member.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.data?.errors) fieldErrors.value = err.response.data.errors;
|
||||
else errorMessage.value = err.response?.data?.message || 'An error occurred.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
form.value = { name: '', username: '', mobile_number: '', password: '' };
|
||||
fieldErrors.value = {};
|
||||
errorMessage.value = '';
|
||||
mobileTaken.value = false;
|
||||
usernameTaken.value = false;
|
||||
done.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const scope = await fetchOfficerScope();
|
||||
ownChapter.value = scope?.own_chapter ?? null;
|
||||
cooperative.value = scope?.cooperative ?? null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container py-4" style="max-width: 560px;">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-user-plus me-2"></i>Create Member</h5>
|
||||
|
||||
<div v-if="loading && !ownChapter" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!ownChapter" class="text-center py-5 text-muted">
|
||||
<i class="fas fa-exclamation-triangle fa-2x text-warning mb-2"></i>
|
||||
<p>You are not assigned to a chapter, so you cannot create members.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="done" class="text-center py-5">
|
||||
<i class="fas fa-check-circle fa-4x text-success mb-3"></i>
|
||||
<h5 class="fw-bold">Member Created!</h5>
|
||||
<p class="text-muted">The new member was added to <strong>{{ ownChapter.name }}</strong>.</p>
|
||||
<button class="btn btn-outline-primary rounded-pill px-4 me-2" @click="reset">Add Another</button>
|
||||
<button class="btn btn-primary rounded-pill px-4" @click="navigate({ page: 'Home' })">Done</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="info-card rounded-4 p-4">
|
||||
<div class="assign-note rounded-3 p-2 mb-3 small">
|
||||
<i class="fas fa-map-marker-alt me-1"></i>
|
||||
Will be added to: <strong>{{ ownChapter.name }}</strong>
|
||||
<span v-if="cooperative"> · {{ cooperative.name }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMessage" class="alert alert-danger rounded-3 small py-2">{{ errorMessage }}</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Full Name</label>
|
||||
<input v-model="form.name" type="text" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': fieldErrors.name }" placeholder="Juan Dela Cruz" />
|
||||
<div v-if="fieldErrors.name" class="invalid-feedback">{{ fieldErrors.name[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Username</label>
|
||||
<input v-model="form.username" type="text" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': usernameTaken || fieldErrors.username }"
|
||||
placeholder="juandelacruz" autocomplete="off" @blur="checkUsername" />
|
||||
<div v-if="usernameTaken" class="invalid-feedback d-block">Username already taken.</div>
|
||||
<div v-else-if="fieldErrors.username" class="invalid-feedback d-block">{{ fieldErrors.username[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Mobile Number</label>
|
||||
<input v-model="form.mobile_number" type="tel" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': mobileError || mobileTaken || fieldErrors.mobile_number }"
|
||||
placeholder="09XXXXXXXXX" @blur="checkMobile" />
|
||||
<div v-if="mobileError" class="invalid-feedback d-block">{{ mobileError }}</div>
|
||||
<div v-else-if="mobileTaken" class="invalid-feedback d-block">Mobile number already taken.</div>
|
||||
<div v-else-if="fieldErrors.mobile_number" class="invalid-feedback d-block">{{ fieldErrors.mobile_number[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-semibold">Password</label>
|
||||
<input v-model="form.password" type="password" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': fieldErrors.password }" placeholder="Min. 6 characters" autocomplete="new-password" />
|
||||
<div v-if="fieldErrors.password" class="invalid-feedback">{{ fieldErrors.password[0] }}</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary rounded-pill w-100 py-2 fw-semibold" :disabled="submitting || !canSubmit" @click="submit">
|
||||
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i v-else class="fas fa-user-plus me-2"></i>
|
||||
{{ submitting ? 'Creating...' : 'Create Member' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.info-card {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.assign-note {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
:global(.dark-mode) .info-card,
|
||||
:global(.dark-mode) .assign-note {
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
</style>
|
||||
@@ -1,364 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Create Cooperative');
|
||||
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const name = ref('');
|
||||
const address = ref('');
|
||||
const registrationNumber = ref('');
|
||||
const cin = ref('');
|
||||
const tin = ref('');
|
||||
const cooperativeType = ref('');
|
||||
const cooperativeCategory = ref('');
|
||||
const registrationDate = ref('');
|
||||
const contactPerson = ref('');
|
||||
const contactNumber = ref('');
|
||||
const contactEmail = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const successMessage = ref('');
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
return !!(loading.value || successMessage.value || !name.value.trim());
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null;
|
||||
successMessage.value = '';
|
||||
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Cooperative name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Cooperatives/Create', {
|
||||
name: name.value.trim(),
|
||||
address: address.value.trim(),
|
||||
registration_number: registrationNumber.value.trim(),
|
||||
cin: cin.value.trim(),
|
||||
tin: tin.value.trim(),
|
||||
cooperative_type: cooperativeType.value,
|
||||
cooperative_category: cooperativeCategory.value,
|
||||
registration_date: registrationDate.value,
|
||||
contact_person: contactPerson.value.trim(),
|
||||
contact_number: contactNumber.value.trim(),
|
||||
contact_email: contactEmail.value.trim(),
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
successMessage.value = 'Cooperative created successfully!';
|
||||
setTimeout(() => {
|
||||
navigate({ page: 'CooperativeList' });
|
||||
}, 1200);
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to create cooperative';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create cooperative:', err);
|
||||
error.value = err.response?.data?.message || 'Failed to create cooperative. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-cooperative-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Register Cooperative</h1>
|
||||
<p class="text-muted">Create a new cooperative organization for farmers and members</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<CardSimple title="Cooperative Details">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="coopName" class="form-label">Cooperative Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="coopName"
|
||||
v-model="name"
|
||||
class="premium-input"
|
||||
placeholder="e.g., Bukidnon Farmers Cooperative"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="coopAddress" class="form-label">Address</label>
|
||||
<textarea
|
||||
id="coopAddress"
|
||||
v-model="address"
|
||||
class="premium-input"
|
||||
rows="2"
|
||||
placeholder="Complete physical address of the cooperative"
|
||||
></textarea>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Registration Information" class="mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="regNum" class="form-label">Registration Number</label>
|
||||
<input type="text" id="regNum" v-model="registrationNumber" class="premium-input" placeholder="e.g. REG-12345">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="cin" class="form-label">CIN (Coop ID Number)</label>
|
||||
<input type="text" id="cin" v-model="cin" class="premium-input" placeholder="e.g. CIN-67890">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="tin" class="form-label">TIN</label>
|
||||
<input type="text" id="tin" v-model="tin" class="premium-input" placeholder="000-000-000-000">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="coopType" class="form-label">Cooperative Type</label>
|
||||
<select id="coopType" v-model="cooperativeType" class="premium-input">
|
||||
<option value="">Select Type</option>
|
||||
<option value="AGRICULTURAL">Agricultural</option>
|
||||
<option value="CREDIT">Credit</option>
|
||||
<option value="CONSUMERS">Consumers</option>
|
||||
<option value="MARKETING">Marketing</option>
|
||||
<option value="SERVICE">Service</option>
|
||||
<option value="MULTIPURPOSE">Multipurpose</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="coopCat" class="form-label">Cooperative Category</label>
|
||||
<select id="coopCat" v-model="cooperativeCategory" class="premium-input">
|
||||
<option value="">Select Category</option>
|
||||
<option value="MICRO">Micro</option>
|
||||
<option value="SMALL">Small</option>
|
||||
<option value="MEDIUM">Medium</option>
|
||||
<option value="LARGE">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Contact Information" class="mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="regDate" class="form-label">Registration Date</label>
|
||||
<input type="date" id="regDate" v-model="registrationDate" class="premium-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="contactPerson" class="form-label">Contact Person</label>
|
||||
<input type="text" id="contactPerson" v-model="contactPerson" class="premium-input" placeholder="Full name">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="contactNum" class="form-label">Contact Number</label>
|
||||
<input type="text" id="contactNum" v-model="contactNumber" class="premium-input" placeholder="e.g. 09123456789">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="premium-input-group">
|
||||
<label for="contactEmail" class="form-label">Contact Email</label>
|
||||
<input type="email" id="contactEmail" v-model="contactEmail" class="premium-input" placeholder="email@example.com">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="action-bar mt-5 text-center">
|
||||
<button
|
||||
@click="handleSubmit"
|
||||
:disabled="isButtonDisabled"
|
||||
class="btn-premium-launch"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
||||
<i v-else class="fas fa-plus-circle me-2"></i>
|
||||
{{ loading ? 'Creating...' : 'Create Cooperative' }}
|
||||
</button>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="navigate({ page: 'CooperativeList' })"
|
||||
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;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.premium-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.premium-input {
|
||||
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 {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 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(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-premium-launch:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,289 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Add Organization');
|
||||
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
import { useUIStore } from '../stores/ui';
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const name = ref('');
|
||||
const type = ref(uiStore.defaultOrgType || 'COOPERATIVE');
|
||||
const address = ref('');
|
||||
|
||||
const orgTypeOptions = computed(() => {
|
||||
const types = uiStore.groupTypes.length ? uiStore.groupTypes : ['COOPERATIVE', 'NGO', 'CORPORATION'];
|
||||
return types.map(t => ({
|
||||
value: t,
|
||||
label: t.charAt(0) + t.slice(1).toLowerCase().replace(/_/g, ' '),
|
||||
}));
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
type.value = uiStore.defaultOrgType || 'COOPERATIVE';
|
||||
});
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const successMessage = ref('');
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
return !!(loading.value || successMessage.value || !name.value.trim() || !type.value);
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null;
|
||||
successMessage.value = '';
|
||||
|
||||
if (!name.value.trim()) {
|
||||
error.value = 'Organization name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Organizations/Create', {
|
||||
name: name.value.trim(),
|
||||
type: type.value,
|
||||
address: address.value.trim(),
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
successMessage.value = 'Organization created successfully!';
|
||||
setTimeout(() => {
|
||||
navigate({ page: 'Home' });
|
||||
}, 1200);
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to create organization';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create organization:', err);
|
||||
error.value = err.response?.data?.message || 'Failed to create organization. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-organization-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Add Organization</h1>
|
||||
<p class="text-muted">Register a new organization (cooperative, association, or company)</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<CardSimple title="Organization Details">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="orgName" class="form-label">Organization Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="orgName"
|
||||
v-model="name"
|
||||
class="premium-input"
|
||||
placeholder="e.g., Bukidnon Farmers Association"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="orgType" class="form-label">Type <span class="required">*</span></label>
|
||||
<select id="orgType" v-model="type" class="premium-input">
|
||||
<option v-for="opt in orgTypeOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="orgAddress" class="form-label">Address</label>
|
||||
<textarea
|
||||
id="orgAddress"
|
||||
v-model="address"
|
||||
class="premium-input"
|
||||
rows="2"
|
||||
placeholder="Complete physical address of the organization"
|
||||
></textarea>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="action-bar mt-5 text-center">
|
||||
<button
|
||||
@click="handleSubmit"
|
||||
:disabled="isButtonDisabled"
|
||||
class="btn-premium-launch"
|
||||
>
|
||||
<i v-if="loading" class="fas fa-spinner fa-spin me-2"></i>
|
||||
<i v-else class="fas fa-plus-circle me-2"></i>
|
||||
{{ loading ? 'Creating...' : 'Create Organization' }}
|
||||
</button>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="navigate({ page: 'Home' })"
|
||||
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;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.premium-input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: #475569;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ef4444;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.premium-input {
|
||||
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 {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 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(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-premium-launch:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,880 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Create Product');
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import { useFileUpload } from '../composables/useFileUpload.js';
|
||||
import { useProductStore } from '../stores/product';
|
||||
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
import Dropzone from '../Components/Core/Dropzone.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
import StockPhotoPicker from '../Components/Core/StockPhotoPicker.vue';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
const productStore = useProductStore();
|
||||
|
||||
const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading, uploadError } = useFileUpload({
|
||||
category: 'ProductMarket',
|
||||
maxSizeMB: 10,
|
||||
});
|
||||
|
||||
const STEP = {
|
||||
PICK: 1,
|
||||
NEW_GLOBAL: 2,
|
||||
DESCRIPTION: 3,
|
||||
ASSIGN_STORES: 4,
|
||||
PER_STORE: 5,
|
||||
};
|
||||
|
||||
const step = ref(STEP.PICK);
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Stores
|
||||
const selectableStores = ref([]);
|
||||
const noStoresChecked = ref(false);
|
||||
|
||||
// Flow mode
|
||||
const mode = ref('existing'); // 'existing' | 'new'
|
||||
|
||||
// Selected existing global product
|
||||
const pickedProduct = ref(null);
|
||||
|
||||
// Search
|
||||
const searchTerm = ref('');
|
||||
const searchResults = ref([]);
|
||||
const isSearching = ref(false);
|
||||
let searchDebounce = null;
|
||||
|
||||
// New global product form
|
||||
const newProduct = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
subcategory: '',
|
||||
price: 1,
|
||||
unitname: '',
|
||||
available: 1,
|
||||
barcode: '',
|
||||
});
|
||||
const categoryList = ref([]);
|
||||
const subcategoryList = ref([]);
|
||||
const dropzoneRef = ref(null);
|
||||
const dropzoneFiles = ref([]);
|
||||
|
||||
// Stock photo picker
|
||||
const showPhotoPicker = ref(false);
|
||||
const onStockPhotoSelected = ({ hashkey, url }) => {
|
||||
// Mirror Dropzone's entry shape: preview drives the thumbnail, hashkey is
|
||||
// what the submit handler filters on for the photourl payload.
|
||||
dropzoneFiles.value.push({ file: null, name: 'stock-photo.jpg', preview: url, hashkey, uploading: false, progress: 100, error: null });
|
||||
};
|
||||
|
||||
// Description override (existing path)
|
||||
const overrideDescription = ref('');
|
||||
|
||||
// Store selection + per-store overrides
|
||||
const assignedStoreHashes = ref([]); // array of store hashkeys
|
||||
const perStoreOverrides = ref({}); // { [storeHash]: { price, available } }
|
||||
|
||||
// ---------------- Bootstrap ----------------
|
||||
onMounted(async () => {
|
||||
isLoading.value = true;
|
||||
await fetchSelectableStores();
|
||||
isLoading.value = false;
|
||||
noStoresChecked.value = true;
|
||||
|
||||
if (selectableStores.value.length === 0) {
|
||||
modal.yesNoModal({
|
||||
title: 'No store found',
|
||||
body: 'You need to create a store before you can add a product. Create one now?',
|
||||
yesText: 'Create Store',
|
||||
onYes: () => navigate({ page: 'CreateStore' }),
|
||||
noText: 'Cancel',
|
||||
onNo: () => navigate({ page: 'Home' }),
|
||||
});
|
||||
} else {
|
||||
loadCategories();
|
||||
}
|
||||
});
|
||||
|
||||
const fetchSelectableStores = async () => {
|
||||
try {
|
||||
const { data } = await axios.post('/Admin/Stores/Selectable');
|
||||
if (data && data.success) selectableStores.value = data.data || [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load stores', e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const { data } = await axios.post('/Products/New/Category/Datalist', {});
|
||||
if (Array.isArray(data)) {
|
||||
categoryList.value = data.map((item) => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : item[1] || item[0],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories', e);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSubcategories = async () => {
|
||||
if (!newProduct.value.category) {
|
||||
subcategoryList.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await axios.post('/Products/New/SubCategory/Datalist', {
|
||||
category: newProduct.value.category,
|
||||
});
|
||||
if (Array.isArray(data)) {
|
||||
subcategoryList.value = data.map((item) => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : item[1] || item[0],
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load subcategories', e);
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => newProduct.value.category, loadSubcategories);
|
||||
|
||||
// ---------------- Search ----------------
|
||||
watch(searchTerm, (val) => {
|
||||
clearTimeout(searchDebounce);
|
||||
if (!val || val.trim().length < 2) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
searchDebounce = setTimeout(runSearch, 300);
|
||||
});
|
||||
|
||||
const runSearch = async () => {
|
||||
if (!searchTerm.value || searchTerm.value.trim().length < 2) return;
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
|
||||
name: searchTerm.value.trim(),
|
||||
});
|
||||
searchResults.value = data && data.success && Array.isArray(data.data) ? data.data : [];
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
searchResults.value = [];
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------- Dropzone ----------------
|
||||
watch(
|
||||
() => dropzoneFiles.value,
|
||||
async (newFiles) => {
|
||||
const filesToUpload = newFiles.filter((f) => !f.uploading && !f.hashkey && !f.error);
|
||||
for (const fileObj of filesToUpload) {
|
||||
const idx = newFiles.indexOf(fileObj);
|
||||
if (idx === -1) continue;
|
||||
dropzoneRef.value.setFileStatus(idx, { uploading: true, progress: 30 });
|
||||
const result = await uploadFile(fileObj.file);
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 100, hashkey: result.hashkey });
|
||||
if (error.value && error.value.startsWith('Photo upload failed:')) {
|
||||
error.value = null;
|
||||
}
|
||||
} else {
|
||||
const msg = uploadError.value || 'Upload failed';
|
||||
dropzoneRef.value.setFileStatus(idx, { uploading: false, progress: 0, error: msg });
|
||||
error.value = `Photo upload failed: ${msg}`;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
if (hashkey) removeHash(hashkey);
|
||||
};
|
||||
|
||||
// ---------------- Step transitions ----------------
|
||||
const selectExistingProduct = (product) => {
|
||||
pickedProduct.value = product;
|
||||
mode.value = 'existing';
|
||||
overrideDescription.value = product.description || '';
|
||||
step.value = STEP.DESCRIPTION;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const startNewProduct = () => {
|
||||
mode.value = 'new';
|
||||
pickedProduct.value = null;
|
||||
step.value = STEP.NEW_GLOBAL;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const validateNewProduct = () => {
|
||||
const p = newProduct.value;
|
||||
if (!p.name) return 'Product name is required';
|
||||
if (!p.description) return 'Description is required';
|
||||
if (!p.category) return 'Category is required';
|
||||
if (!p.subcategory) return 'Subcategory is required';
|
||||
if (!p.price || parseFloat(p.price) <= 0) return 'Valid price is required';
|
||||
if (!p.unitname) return 'Unit name is required';
|
||||
const hasPhoto = dropzoneFiles.value.some((f) => !!f.hashkey);
|
||||
if (!hasPhoto) return 'At least one photo is required';
|
||||
if (p.barcode && !/^\d{12}$/.test(p.barcode)) return 'Barcode must be exactly 12 digits';
|
||||
return null;
|
||||
};
|
||||
|
||||
const advanceFromNewGlobal = () => {
|
||||
const err = validateNewProduct();
|
||||
if (err) {
|
||||
error.value = err;
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
step.value = STEP.ASSIGN_STORES;
|
||||
};
|
||||
|
||||
const advanceFromDescription = () => {
|
||||
if (!overrideDescription.value || !overrideDescription.value.trim()) {
|
||||
error.value = 'Description is required';
|
||||
return;
|
||||
}
|
||||
error.value = null;
|
||||
step.value = STEP.ASSIGN_STORES;
|
||||
};
|
||||
|
||||
const toggleStore = (hash) => {
|
||||
const i = assignedStoreHashes.value.indexOf(hash);
|
||||
if (i >= 0) {
|
||||
assignedStoreHashes.value.splice(i, 1);
|
||||
delete perStoreOverrides.value[hash];
|
||||
} else {
|
||||
assignedStoreHashes.value.push(hash);
|
||||
}
|
||||
};
|
||||
|
||||
const advanceFromStores = () => {
|
||||
if (assignedStoreHashes.value.length === 0) {
|
||||
error.value = 'Select at least one store.';
|
||||
return;
|
||||
}
|
||||
// Seed per-store defaults from the global product.
|
||||
const defaultPrice = mode.value === 'new'
|
||||
? parseFloat(newProduct.value.price) || 0
|
||||
: parseFloat(pickedProduct.value?.price) || 0;
|
||||
const defaultAvailable = mode.value === 'new'
|
||||
? parseInt(newProduct.value.available) || 1
|
||||
: 1;
|
||||
|
||||
for (const hash of assignedStoreHashes.value) {
|
||||
if (!perStoreOverrides.value[hash]) {
|
||||
perStoreOverrides.value[hash] = {
|
||||
price: defaultPrice,
|
||||
available: defaultAvailable,
|
||||
};
|
||||
}
|
||||
}
|
||||
error.value = null;
|
||||
step.value = STEP.PER_STORE;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
error.value = null;
|
||||
if (step.value === STEP.PER_STORE) {
|
||||
step.value = STEP.ASSIGN_STORES;
|
||||
} else if (step.value === STEP.ASSIGN_STORES) {
|
||||
step.value = mode.value === 'new' ? STEP.NEW_GLOBAL : STEP.DESCRIPTION;
|
||||
} else if (step.value === STEP.DESCRIPTION || step.value === STEP.NEW_GLOBAL) {
|
||||
step.value = STEP.PICK;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------- Submit ----------------
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value) return;
|
||||
|
||||
for (const hash of assignedStoreHashes.value) {
|
||||
const ov = perStoreOverrides.value[hash];
|
||||
if (!ov || !ov.price || parseFloat(ov.price) <= 0) {
|
||||
error.value = 'Each assigned store needs a valid price.';
|
||||
return;
|
||||
}
|
||||
if (ov.available === '' || ov.available === null || parseInt(ov.available) < 0) {
|
||||
error.value = 'Each assigned store needs a valid availability.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
let productHash = pickedProduct.value?.hashkey;
|
||||
let storesToAssign = [...assignedStoreHashes.value];
|
||||
|
||||
if (mode.value === 'new') {
|
||||
const firstStore = storesToAssign[0];
|
||||
const photoHashList = dropzoneFiles.value.filter((f) => f.hashkey).map((f) => f.hashkey);
|
||||
const { data } = await axios.post('/Products/Admin/New/', {
|
||||
NewProductName: newProduct.value.name,
|
||||
NewProductDescription: newProduct.value.description,
|
||||
NewProductCategory: newProduct.value.category,
|
||||
NewProductSubCategory: newProduct.value.subcategory,
|
||||
NewProductPrice: parseFloat(newProduct.value.price),
|
||||
NewProductUnitName: newProduct.value.unitname,
|
||||
NewProductAvailable: parseInt(newProduct.value.available),
|
||||
NewProductBarcode: newProduct.value.barcode,
|
||||
TargetStore: firstStore,
|
||||
photourl: photoHashList,
|
||||
});
|
||||
if (!data || !data.success) {
|
||||
error.value = data?.message || 'Failed to create global product.';
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
productHash = data.data?.hashkey || data.hashkey;
|
||||
}
|
||||
|
||||
const descriptionOverride =
|
||||
mode.value === 'new' ? newProduct.value.description : overrideDescription.value;
|
||||
|
||||
const failures = [];
|
||||
for (const hash of storesToAssign) {
|
||||
const ov = perStoreOverrides.value[hash];
|
||||
try {
|
||||
await axios.post('/Products/AssignToStore/', {
|
||||
target: productHash,
|
||||
TargetStore: hash,
|
||||
price: parseFloat(ov.price),
|
||||
available: parseInt(ov.available),
|
||||
description: descriptionOverride,
|
||||
});
|
||||
} catch (e) {
|
||||
const storeName = selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
|
||||
failures.push(storeName);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length === storesToAssign.length) {
|
||||
error.value = 'Failed to assign product to any selected store.';
|
||||
isSubmitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
productStore.fetchProducts();
|
||||
|
||||
modal.quickDismiss({
|
||||
title: 'Product Listed',
|
||||
body:
|
||||
failures.length > 0
|
||||
? `Listed in ${storesToAssign.length - failures.length} store(s). Failed for: ${failures.join(', ')}.`
|
||||
: `Your product is now listed in ${storesToAssign.length} store(s).`,
|
||||
onShown: () => {
|
||||
setTimeout(() => navigate({ page: 'ManageProductsAdmin' }), 1200);
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Submit failed', e);
|
||||
error.value = e.response?.data?.message || 'Failed to create product.';
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------- Helpers ----------------
|
||||
const stepTitle = computed(() => {
|
||||
switch (step.value) {
|
||||
case STEP.PICK:
|
||||
return 'Find your product';
|
||||
case STEP.NEW_GLOBAL:
|
||||
return 'Create new product';
|
||||
case STEP.DESCRIPTION:
|
||||
return 'Describe your product';
|
||||
case STEP.ASSIGN_STORES:
|
||||
return 'Assign to stores';
|
||||
case STEP.PER_STORE:
|
||||
return 'Price & availability per store';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const stepNumber = computed(() => {
|
||||
if (step.value === STEP.PICK) return 1;
|
||||
if (step.value === STEP.NEW_GLOBAL || step.value === STEP.DESCRIPTION) return 2;
|
||||
if (step.value === STEP.ASSIGN_STORES) return 3;
|
||||
if (step.value === STEP.PER_STORE) return 4;
|
||||
return 1;
|
||||
});
|
||||
|
||||
const storeName = (hash) =>
|
||||
selectableStores.value.find((s) => s.hashkey === hash)?.name || hash;
|
||||
|
||||
const globalDefaultPrice = computed(() =>
|
||||
mode.value === 'new'
|
||||
? parseFloat(newProduct.value.price) || 0
|
||||
: parseFloat(pickedProduct.value?.price) || 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="csop-page">
|
||||
<div class="tf-container mt-4 mb-3 text-center">
|
||||
<h1 class="fw_8 page-title">Add a Product to Your Store</h1>
|
||||
<p class="text-muted small mb-0">Step {{ stepNumber }} of 4 — {{ stepTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="tf-container mb-3">
|
||||
<div class="glass-alert alert-danger">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="tf-container text-center py-5">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="noStoresChecked && selectableStores.length === 0" class="tf-container text-center py-5">
|
||||
<p class="text-muted">You need a store before adding products.</p>
|
||||
<button class="btn btn-primary rounded-pill px-4 mt-2" @click="navigate({ page: 'CreateStore' })">
|
||||
Create a Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tf-container">
|
||||
<!-- STEP 1: Pick existing or new -->
|
||||
<div v-if="step === STEP.PICK">
|
||||
<CardSimple title="Search for your product" cardStyle="height: auto">
|
||||
<p class="text-muted small">
|
||||
Many products are already in our system. Type a product name to see if yours exists.
|
||||
</p>
|
||||
<div class="premium-input-group mb-3">
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchTerm"
|
||||
class="premium-input"
|
||||
placeholder="e.g., Premium Rice"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isSearching" class="text-center py-3">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0" class="results-list">
|
||||
<div
|
||||
v-for="m in searchResults"
|
||||
:key="m.hashkey"
|
||||
class="result-row"
|
||||
@click="selectExistingProduct(m)"
|
||||
>
|
||||
<FileImage
|
||||
:src="m.photourl && m.photourl[0] ? m.photourl[0] : ''"
|
||||
:alt="m.name"
|
||||
class="result-thumb"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
||||
/>
|
||||
<div class="result-info">
|
||||
<div class="fw_6">{{ m.name }}</div>
|
||||
<div class="text-muted smallest">
|
||||
<span v-if="m.category">{{ m.category }}<span v-if="m.subcategory"> · {{ m.subcategory }}</span> · </span>
|
||||
<span>₱{{ m.price }} / {{ m.unitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="fas fa-chevron-right text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="searchTerm && searchTerm.length >= 2"
|
||||
class="text-muted smallest text-center py-3"
|
||||
>
|
||||
No matches yet. You can create a new product below.
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<button class="btn btn-outline-primary rounded-pill px-4" @click="startNewProduct">
|
||||
<i class="fas fa-plus me-2"></i> My product is not listed — Create new
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<button class="btn-text" @click="navigate({ page: 'Home' })">
|
||||
<i class="fas fa-chevron-left me-2"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2 (NEW): Full new global product form -->
|
||||
<div v-else-if="step === STEP.NEW_GLOBAL">
|
||||
<CardSimple title="New product details">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Product Name <span class="required">*</span></label>
|
||||
<input type="text" v-model="newProduct.name" class="premium-input" placeholder="e.g., Premium Rice" />
|
||||
</div>
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Description <span class="required">*</span></label>
|
||||
<textarea v-model="newProduct.description" class="premium-input" rows="3" placeholder="Describe your product..."></textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Category <span class="required">*</span></label>
|
||||
<select v-model="newProduct.category" class="premium-select">
|
||||
<option value="" disabled>Select Category</option>
|
||||
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Subcategory <span class="required">*</span></label>
|
||||
<select v-model="newProduct.subcategory" class="premium-select" :disabled="subcategoryList.length === 0">
|
||||
<option value="" disabled>Select Subcategory</option>
|
||||
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Base Price (PHP) <span class="required">*</span></label>
|
||||
<input type="number" v-model="newProduct.price" class="premium-input" min="1" step="0.01" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Unit <span class="required">*</span></label>
|
||||
<input type="text" v-model="newProduct.unitname" class="premium-input" placeholder="e.g., 25kg" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Default Available <span class="required">*</span></label>
|
||||
<input type="number" v-model="newProduct.available" class="premium-input" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="premium-input-group mb-3">
|
||||
<label class="form-label">Barcode (12 digits)</label>
|
||||
<input type="text" v-model="newProduct.barcode" class="premium-input" maxlength="12" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="premium-input-group">
|
||||
<label class="form-label">Product Photos <span class="required">*</span></label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
|
||||
@click="showPhotoPicker = true">
|
||||
<i class="fas fa-images me-1"></i> Search Stock Photos
|
||||
</button>
|
||||
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
|
||||
<StockPhotoPicker v-model="showPhotoPicker" :product-name="newProduct.name"
|
||||
@photo-selected="onStockPhotoSelected" />
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="nav-bar mt-4">
|
||||
<button class="btn-text" @click="goBack">
|
||||
<i class="fas fa-chevron-left me-2"></i> Back
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary rounded-pill px-4"
|
||||
:disabled="isFileUploading"
|
||||
@click="advanceFromNewGlobal"
|
||||
>
|
||||
Next <i class="fas fa-chevron-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2 (EXISTING): Description override -->
|
||||
<div v-else-if="step === STEP.DESCRIPTION">
|
||||
<CardSimple :title="pickedProduct?.name || 'Selected product'">
|
||||
<div class="picked-preview mb-3">
|
||||
<FileImage
|
||||
:src="pickedProduct?.photourl && pickedProduct.photourl[0] ? pickedProduct.photourl[0] : ''"
|
||||
:alt="pickedProduct?.name"
|
||||
class="picked-thumb"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
||||
/>
|
||||
<div class="text-muted small">
|
||||
<span v-if="pickedProduct?.category">{{ pickedProduct.category }}<span v-if="pickedProduct.subcategory"> · {{ pickedProduct.subcategory }}</span> · </span>
|
||||
<span>₱{{ pickedProduct?.price }} / {{ pickedProduct?.unitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group">
|
||||
<label class="form-label">Description for your listing <span class="required">*</span></label>
|
||||
<textarea
|
||||
v-model="overrideDescription"
|
||||
class="premium-input"
|
||||
rows="5"
|
||||
placeholder="Describe how this product appears in your store..."
|
||||
></textarea>
|
||||
<p class="smallest text-muted mt-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
This description will be shown for this product across the stores you assign it to.
|
||||
</p>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="nav-bar mt-4">
|
||||
<button class="btn-text" @click="goBack">
|
||||
<i class="fas fa-chevron-left me-2"></i> Back
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromDescription">
|
||||
Next <i class="fas fa-chevron-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: Assign to stores -->
|
||||
<div v-else-if="step === STEP.ASSIGN_STORES">
|
||||
<CardSimple title="Which of your stores should sell this?">
|
||||
<p class="text-muted small">Pick one or more stores.</p>
|
||||
<div class="store-list">
|
||||
<label
|
||||
v-for="s in selectableStores"
|
||||
:key="s.hashkey"
|
||||
class="store-row"
|
||||
:class="{ 'is-selected': assignedStoreHashes.includes(s.hashkey) }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="assignedStoreHashes.includes(s.hashkey)"
|
||||
@change="toggleStore(s.hashkey)"
|
||||
/>
|
||||
<div>
|
||||
<div class="fw_6">{{ s.name }}</div>
|
||||
<div class="text-muted smallest">{{ s.role }}<span v-if="s.category"> · {{ s.category }}</span></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="nav-bar mt-4">
|
||||
<button class="btn-text" @click="goBack">
|
||||
<i class="fas fa-chevron-left me-2"></i> Back
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-pill px-4" @click="advanceFromStores">
|
||||
Next <i class="fas fa-chevron-right ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 4: Per-store overrides -->
|
||||
<div v-else-if="step === STEP.PER_STORE">
|
||||
<CardSimple title="Set price and stock for each store">
|
||||
<p class="text-muted small">
|
||||
Defaults come from the product's base price (₱{{ globalDefaultPrice }}). Adjust per store as needed.
|
||||
</p>
|
||||
<div class="per-store-list">
|
||||
<div
|
||||
v-for="hash in assignedStoreHashes"
|
||||
:key="hash"
|
||||
class="per-store-row"
|
||||
>
|
||||
<div class="per-store-name">
|
||||
<i class="fas fa-store me-2 text-muted"></i>
|
||||
<span class="fw_6">{{ storeName(hash) }}</span>
|
||||
</div>
|
||||
<div class="per-store-fields">
|
||||
<div class="premium-input-group">
|
||||
<label class="form-label smallest">Price (PHP)</label>
|
||||
<input
|
||||
type="number"
|
||||
class="premium-input"
|
||||
min="1"
|
||||
step="0.01"
|
||||
v-model="perStoreOverrides[hash].price"
|
||||
/>
|
||||
</div>
|
||||
<div class="premium-input-group">
|
||||
<label class="form-label smallest">Available</label>
|
||||
<input
|
||||
type="number"
|
||||
class="premium-input"
|
||||
min="0"
|
||||
v-model="perStoreOverrides[hash].available"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<div class="nav-bar mt-4">
|
||||
<button class="btn-text" @click="goBack">
|
||||
<i class="fas fa-chevron-left me-2"></i> Back
|
||||
</button>
|
||||
<AnimatedButton
|
||||
@click="submit"
|
||||
:disabled="isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
btnClass="btn-premium-launch"
|
||||
>
|
||||
List Product
|
||||
</AnimatedButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.csop-page {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
background: linear-gradient(135deg, #1e293b 0%, #334155 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.premium-input-group { display: flex; flex-direction: column; }
|
||||
.form-label { font-weight: 600; font-size: 0.9rem; color: #475569; margin-bottom: 6px; }
|
||||
.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;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.premium-input:focus, .premium-select:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.glass-alert {
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.alert-danger {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.results-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||
.result-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.result-row:hover { border-color: #93c5fd; background: rgba(59, 130, 246, 0.04); }
|
||||
.result-thumb {
|
||||
width: 48px; height: 48px; border-radius: 10px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.result-info { flex: 1; min-width: 0; }
|
||||
|
||||
.picked-preview { display: flex; align-items: center; gap: 12px; }
|
||||
.picked-thumb { width: 72px; height: 72px; border-radius: 12px; object-fit: cover; }
|
||||
|
||||
.store-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.store-row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.store-row:hover { border-color: #93c5fd; }
|
||||
.store-row.is-selected { border-color: #2563eb; background: rgba(37, 99, 235, 0.06); }
|
||||
|
||||
.per-store-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.per-store-row {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.per-store-name { display: flex; align-items: center; }
|
||||
.per-store-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-text:hover { color: #1e293b; }
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 32px;
|
||||
border-radius: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 15px -3px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.btn-premium-launch:disabled { background: #cbd5e1; cursor: not-allowed; box-shadow: none; }
|
||||
|
||||
:global(.dark-mode) .premium-input,
|
||||
:global(.dark-mode) .premium-select {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
:global(.dark-mode) .form-label { color: #94a3b8; }
|
||||
:global(.dark-mode) .result-row,
|
||||
:global(.dark-mode) .store-row,
|
||||
:global(.dark-mode) .per-store-row { border-color: #334155; }
|
||||
:global(.dark-mode) .page-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
@@ -1,954 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Create Product Ultimate');
|
||||
|
||||
import { ref, onMounted, watch, computed } 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'
|
||||
import Dropzone from '../Components/Core/Dropzone.vue'
|
||||
import FileImage from '../Components/Core/FileImage.vue'
|
||||
import StockPhotoPicker from '../Components/Core/StockPhotoPicker.vue'
|
||||
import { useFileUpload } from '../composables/useFileUpload.js'
|
||||
import { useProductStore } from '../stores/product'
|
||||
import { useAuth } from '../composables/Core/useAuth'
|
||||
|
||||
const productStore = useProductStore()
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
const { isUltimate, isSuperOperator, isOperator } = useAuth()
|
||||
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value)
|
||||
const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading } = useFileUpload({
|
||||
category: 'ProductMarket',
|
||||
maxSizeMB: 10
|
||||
})
|
||||
|
||||
// Form state
|
||||
const productName = ref('')
|
||||
const productDescription = ref('')
|
||||
const productCategory = ref('')
|
||||
const productSubcategory = ref('')
|
||||
const productPrice = ref(1)
|
||||
const productUnitName = ref('')
|
||||
const productAvailable = ref(1)
|
||||
const productBarcode = ref('')
|
||||
const selectedStore = ref('')
|
||||
const selectableStores = ref([])
|
||||
|
||||
// Data lists
|
||||
const categoryList = ref([])
|
||||
const subcategoryList = ref([])
|
||||
|
||||
// Loading state
|
||||
const isLoading = ref(false)
|
||||
const showSuccessState = ref(false)
|
||||
const showSuccessAnimation = ref(false)
|
||||
const successMessage = ref('')
|
||||
const error = ref(null)
|
||||
|
||||
|
||||
// Initialize component
|
||||
onMounted(() => {
|
||||
document.title = 'New Product'
|
||||
loadCategories()
|
||||
fetchSelectableStores()
|
||||
})
|
||||
|
||||
const fetchSelectableStores = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Admin/Stores/Selectable')
|
||||
if (response.data && response.data.success) {
|
||||
selectableStores.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stores:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load categories
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Products/New/Category/Datalist', {})
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
categoryList.value = response.data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load subcategories when category changes
|
||||
const loadSubcategories = async () => {
|
||||
if (!productCategory.value) {
|
||||
subcategoryList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/Products/New/SubCategory/Datalist', {
|
||||
category: productCategory.value
|
||||
})
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
subcategoryList.value = response.data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading subcategories:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Dropzone handling
|
||||
const dropzoneRef = ref(null)
|
||||
const dropzoneFiles = ref([])
|
||||
|
||||
// Stock photo picker
|
||||
const showPhotoPicker = ref(false)
|
||||
const onStockPhotoSelected = ({ hashkey, url }) => {
|
||||
// Mirror the entry shape Dropzone produces: preview drives the thumbnail,
|
||||
// hashkey is what handleSubmit filters on for the photourl payload.
|
||||
dropzoneFiles.value.push({ file: null, name: 'stock-photo.jpg', preview: url, hashkey, uploading: false, progress: 100, error: null })
|
||||
}
|
||||
|
||||
// Watch for new files in dropzone and upload them
|
||||
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
|
||||
// Find files that are not yet uploading and don't have a hashkey
|
||||
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
|
||||
|
||||
for (const fileObj of filesToUpload) {
|
||||
const index = newFiles.indexOf(fileObj);
|
||||
if (index === -1) continue;
|
||||
|
||||
// Set uploading status
|
||||
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
|
||||
|
||||
const result = await uploadFile(fileObj.file);
|
||||
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
hashkey: result.hashkey
|
||||
});
|
||||
} else {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
error: 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
if (hashkey) {
|
||||
removeHash(hashkey);
|
||||
}
|
||||
};
|
||||
|
||||
// Update subcategory list when category changes
|
||||
const handleCategoryChange = () => {
|
||||
loadSubcategories()
|
||||
}
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
if (!productName.value) {
|
||||
error.value = 'Product name is required'
|
||||
return false
|
||||
}
|
||||
if (!productDescription.value) {
|
||||
error.value = 'Product description is required'
|
||||
return false
|
||||
}
|
||||
if (!productCategory.value) {
|
||||
error.value = 'Category is required'
|
||||
return false
|
||||
}
|
||||
if (!productSubcategory.value) {
|
||||
error.value = 'Subcategory is required'
|
||||
return false
|
||||
}
|
||||
if (!productPrice.value || parseFloat(productPrice.value) <= 0) {
|
||||
error.value = 'Valid price is required'
|
||||
return false
|
||||
}
|
||||
if (!productUnitName.value) {
|
||||
error.value = 'Unit name is required'
|
||||
return false
|
||||
}
|
||||
const hasFiles = dropzoneFiles.value.length > 0;
|
||||
const hasHashes = photoHashes.value.length > 0 || dropzoneFiles.value.some(f => !!f.hashkey);
|
||||
|
||||
if (!hasFiles && !hasHashes) {
|
||||
error.value = 'At least one photo is required'
|
||||
return false
|
||||
}
|
||||
if (productBarcode.value && !/^\d{12}$/.test(productBarcode.value)) {
|
||||
error.value = 'Barcode must be exactly 12 digits'
|
||||
return false
|
||||
}
|
||||
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
// Submit product
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await axios.post('/Products/Admin/New/', {
|
||||
NewProductName: productName.value,
|
||||
NewProductDescription: productDescription.value,
|
||||
NewProductCategory: productCategory.value,
|
||||
NewProductSubCategory: productSubcategory.value,
|
||||
NewProductPrice: parseFloat(productPrice.value),
|
||||
NewProductUnitName: productUnitName.value,
|
||||
NewProductAvailable: parseInt(productAvailable.value),
|
||||
NewProductBarcode: productBarcode.value,
|
||||
TargetStore: selectedStore.value,
|
||||
photourl: dropzoneFiles.value
|
||||
.filter(f => f.hashkey)
|
||||
.map(f => f.hashkey)
|
||||
})
|
||||
|
||||
if (response.data && (response.data.success || typeof response.data === 'string')) {
|
||||
showSuccessState.value = true;
|
||||
showSuccessAnimation.value = true;
|
||||
successMessage.value = 'Product created successfully!'
|
||||
|
||||
// Proactively prefetch products list
|
||||
productStore.fetchProducts()
|
||||
|
||||
setTimeout(() => {
|
||||
navigate({ page: 'ManageProductsAdmin' })
|
||||
}, 1500)
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to create product'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating product:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to create product'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
return !!(isLoading.value || successMessage.value || isFileUploading.value);
|
||||
});
|
||||
|
||||
// --- Fuzzy duplicate check + store-picker flow ---------------------------------
|
||||
|
||||
const fuzzyMatches = ref([])
|
||||
const showMatchesModal = ref(false)
|
||||
const showStorePickerModal = ref(false)
|
||||
const isCheckingDuplicates = ref(false)
|
||||
const isImporting = ref(false)
|
||||
const pickerStore = ref('')
|
||||
|
||||
const checkDuplicatesAndProceed = async () => {
|
||||
if (!validateForm()) return
|
||||
isCheckingDuplicates.value = true
|
||||
try {
|
||||
const { data } = await axios.post('/Products/Admin/FuzzySearch', {
|
||||
name: productName.value,
|
||||
TargetStore: selectedStore.value || pickerStore.value || ''
|
||||
})
|
||||
const matches = (data && data.success && Array.isArray(data.data)) ? data.data : []
|
||||
if (matches.length > 0) {
|
||||
fuzzyMatches.value = matches
|
||||
showMatchesModal.value = true
|
||||
} else {
|
||||
openStorePicker()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Fuzzy search failed:', err)
|
||||
openStorePicker()
|
||||
} finally {
|
||||
isCheckingDuplicates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openStorePicker = () => {
|
||||
showMatchesModal.value = false
|
||||
pickerStore.value = isBig3.value ? '' : (selectedStore.value || (selectableStores.value[0]?.hashkey ?? ''))
|
||||
showStorePickerModal.value = true
|
||||
}
|
||||
|
||||
const confirmAndCreate = async () => {
|
||||
if (!isBig3.value && selectableStores.value.length > 0 && !pickerStore.value) {
|
||||
error.value = 'Please select a store to assign this product to.'
|
||||
return
|
||||
}
|
||||
selectedStore.value = pickerStore.value
|
||||
showStorePickerModal.value = false
|
||||
await handleSubmit()
|
||||
}
|
||||
|
||||
const importExistingProduct = async (match) => {
|
||||
if (match.already_in_store) return
|
||||
const targetStore = selectedStore.value || pickerStore.value
|
||||
if (!targetStore) {
|
||||
// Need to pick a store first.
|
||||
showMatchesModal.value = false
|
||||
pickerStore.value = selectableStores.value[0]?.hashkey ?? ''
|
||||
showStorePickerModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isImporting.value = true
|
||||
const { data } = await axios.post('/Products/AssignToStore/', {
|
||||
target: match.hashkey,
|
||||
TargetStore: targetStore,
|
||||
price: parseFloat(productPrice.value) || match.price,
|
||||
available: parseInt(productAvailable.value) || 0,
|
||||
})
|
||||
if (data && data.success) {
|
||||
showMatchesModal.value = false
|
||||
showSuccessState.value = true
|
||||
showSuccessAnimation.value = true
|
||||
successMessage.value = `${match.name} imported to your store.`
|
||||
productStore.fetchProducts()
|
||||
setTimeout(() => navigate({ page: 'ManageProductsAdmin' }), 1500)
|
||||
} else {
|
||||
error.value = data?.message || 'Failed to import product.'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
error.value = err.response?.data?.message || 'Failed to import product.'
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-product-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Create New Product</h1>
|
||||
<p class="text-muted">Fill in the details to list your product in the market</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<div class="form-grid">
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Product Details">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="productName" class="form-label">Product Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="productName"
|
||||
v-model="productName"
|
||||
class="premium-input"
|
||||
placeholder="e.g., Premium Rice"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="productDescription" class="form-label">Description <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="productDescription"
|
||||
v-model="productDescription"
|
||||
class="premium-input"
|
||||
rows="4"
|
||||
placeholder="Describe your product..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="category" class="form-label">Category <span class="required">*</span></label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="productCategory"
|
||||
class="premium-select"
|
||||
@change="handleCategoryChange"
|
||||
>
|
||||
<option value="" disabled>Select Category</option>
|
||||
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="subcategory" class="form-label">Subcategory <span class="required">*</span></label>
|
||||
<select
|
||||
id="subcategory"
|
||||
v-model="productSubcategory"
|
||||
class="premium-select"
|
||||
:disabled="subcategoryList.length === 0"
|
||||
>
|
||||
<option value="" disabled>Select Subcategory</option>
|
||||
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">
|
||||
{{ sub.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectableStores.length > 0" class="premium-input-group mb-4 mt-2 border-top pt-4">
|
||||
<label for="targetStore" class="form-label">Assign to Store (Optional)</label>
|
||||
<select
|
||||
id="targetStore"
|
||||
v-model="selectedStore"
|
||||
class="premium-select shadow-sm"
|
||||
>
|
||||
<option value="">No Store (Global Product Template)</option>
|
||||
<option v-for="store in selectableStores" :key="store.hashkey" :value="store.hashkey">
|
||||
{{ store.name }} ({{ store.role }})
|
||||
</option>
|
||||
</select>
|
||||
<p class="smallest text-muted mt-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Select a store to list this product immediately after creation.
|
||||
</p>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Inventory & Photos -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Inventory & Pricing">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="price" class="form-label">Price (PHP) <span class="required">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
v-model="productPrice"
|
||||
class="premium-input"
|
||||
min="1"
|
||||
step="0.01"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="unit" class="form-label">Unit <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="unit"
|
||||
v-model="productUnitName"
|
||||
class="premium-input"
|
||||
placeholder="e.g., 25kg"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="available" class="form-label">Available Stock <span class="required">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
id="available"
|
||||
v-model="productAvailable"
|
||||
class="premium-input"
|
||||
min="1"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="barcode" class="form-label">Barcode (12 Digits)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="barcode"
|
||||
v-model="productBarcode"
|
||||
class="premium-input"
|
||||
maxlength="12"
|
||||
placeholder="Optional"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-2">
|
||||
<label class="form-label">Product Photos <span class="required">*</span></label>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
|
||||
@click="showPhotoPicker = true">
|
||||
<i class="fas fa-images me-1"></i> Search Stock Photos
|
||||
</button>
|
||||
<Dropzone
|
||||
ref="dropzoneRef"
|
||||
v-model:files="dropzoneFiles"
|
||||
@removed="handlePhotoRemoved"
|
||||
/>
|
||||
<StockPhotoPicker v-model="showPhotoPicker" :product-name="productName"
|
||||
@photo-selected="onStockPhotoSelected" />
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar mt-5 text-center">
|
||||
<AnimatedButton
|
||||
@click="checkDuplicatesAndProceed"
|
||||
:disabled="isButtonDisabled || isCheckingDuplicates"
|
||||
btnClass="btn-premium-launch"
|
||||
:loading="isLoading || isCheckingDuplicates"
|
||||
:success="showSuccessState"
|
||||
>
|
||||
Create Product
|
||||
</AnimatedButton>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="navigate({ page: 'ManageProductsAdmin' })"
|
||||
class="btn-text"
|
||||
>
|
||||
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fuzzy Match Modal -->
|
||||
<div v-if="showMatchesModal" class="bb-modal-backdrop" @click.self="showMatchesModal = false">
|
||||
<div class="bb-modal">
|
||||
<div class="bb-modal-header">
|
||||
<div>
|
||||
<h4 class="fw_7 mb-1">Similar products already exist</h4>
|
||||
<p class="text-muted small mb-0">Import one of these into your store instead of creating a duplicate, or continue creating a new product.</p>
|
||||
</div>
|
||||
<button class="bb-modal-close" @click="showMatchesModal = false" aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bb-modal-body">
|
||||
<div v-for="m in fuzzyMatches" :key="m.hashkey" class="match-row">
|
||||
<div class="match-row-top">
|
||||
<FileImage
|
||||
:src="m.photourl && m.photourl[0] ? m.photourl[0] : ''"
|
||||
:alt="m.name"
|
||||
class="match-thumb"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
||||
/>
|
||||
<div class="match-info">
|
||||
<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 your store
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary rounded-pill w-100"
|
||||
:disabled="m.already_in_store || isImporting"
|
||||
@click="importExistingProduct(m)"
|
||||
>
|
||||
<span v-if="isImporting"><LoadingSpinner size="small" /></span>
|
||||
<span v-else-if="m.already_in_store">In Store</span>
|
||||
<span v-else>Import to Store</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bb-modal-footer">
|
||||
<button class="btn btn-link text-muted" @click="showMatchesModal = false">Cancel</button>
|
||||
<button class="btn btn-outline-primary rounded-pill px-4" @click="openStorePicker">
|
||||
None of these — Create new
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Picker Modal -->
|
||||
<div v-if="showStorePickerModal" class="bb-modal-backdrop" @click.self="showStorePickerModal = false">
|
||||
<div class="bb-modal bb-modal-small">
|
||||
<div class="bb-modal-header">
|
||||
<div>
|
||||
<h4 class="fw_7 mb-1">Assign new product to a store</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
Pick the store this product will be listed in.
|
||||
<span v-if="isBig3" class="ms-1 badge bg-info-subtle text-info rounded-pill" style="font-size:0.7em">Optional for your account</span>
|
||||
</p>
|
||||
</div>
|
||||
<button class="bb-modal-close" @click="showStorePickerModal = false" aria-label="Close">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bb-modal-body">
|
||||
<div v-if="selectableStores.length === 0" class="text-muted">
|
||||
You don't have any stores yet. Create one first.
|
||||
</div>
|
||||
<div v-else class="store-picker-list">
|
||||
<label
|
||||
v-for="store in selectableStores"
|
||||
:key="store.hashkey"
|
||||
class="store-picker-row"
|
||||
:class="{ 'is-selected': pickerStore === store.hashkey }"
|
||||
>
|
||||
<input type="radio" :value="store.hashkey" v-model="pickerStore" />
|
||||
<div>
|
||||
<div class="fw_6">{{ store.name }}</div>
|
||||
<div class="text-muted smallest">{{ store.role }}<span v-if="store.category"> · {{ store.category }}</span></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="isBig3 && !pickerStore" class="text-muted small mt-2 mb-0">
|
||||
<i class="fas fa-info-circle me-1"></i>No store selected — product will be created as a global listing only.
|
||||
</p>
|
||||
</div>
|
||||
<div class="bb-modal-footer">
|
||||
<button class="btn btn-link text-muted" @click="showStorePickerModal = false">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary rounded-pill px-4"
|
||||
:disabled="isLoading || (!isBig3 && selectableStores.length > 0 && !pickerStore)"
|
||||
@click="confirmAndCreate"
|
||||
>
|
||||
<span v-if="isLoading"><LoadingSpinner size="small" class="me-2" /> Creating...</span>
|
||||
<span v-else>Confirm & Create</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Animation Overlay -->
|
||||
<div v-if="showSuccessAnimation" class="success-overlay">
|
||||
<div class="text-center animate-bounce-in">
|
||||
<LottiePlayer
|
||||
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
|
||||
:loop="false"
|
||||
width="250px"
|
||||
height="250px"
|
||||
/>
|
||||
<h2 class="fw_8 mt-4 text-primary headline-gradient">Product Created!</h2>
|
||||
<p class="text-muted">Your product is now listed in the market.</p>
|
||||
</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;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
|
||||
.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.9rem;
|
||||
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-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 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(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-premium-launch:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
padding: 12px 48px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.success-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .success-overlay {
|
||||
background: rgba(18, 20, 24, 0.98);
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
70% { transform: scale(0.9); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.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: 640px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.35);
|
||||
}
|
||||
.bb-modal-small { max-width: 480px; }
|
||||
.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;
|
||||
}
|
||||
.match-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.match-row-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.match-row:last-child { border-bottom: none; }
|
||||
.match-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.match-info { flex: 1; min-width: 0; }
|
||||
.store-picker-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.store-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.store-picker-row:hover { border-color: #93c5fd; }
|
||||
.store-picker-row.is-selected {
|
||||
border-color: #2563eb;
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
:global(.dark-mode) .bb-modal {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
:global(.dark-mode) .bb-modal-header,
|
||||
:global(.dark-mode) .bb-modal-footer { border-color: #334155; }
|
||||
:global(.dark-mode) .store-picker-row { border-color: #334155; }
|
||||
:global(.dark-mode) .match-row { border-bottom-color: #334155; }
|
||||
|
||||
.headline-gradient {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,733 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Create Store');
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import { useAuth } from '../composables/Core/useAuth.js';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
import Dropzone from '../Components/Core/Dropzone.vue';
|
||||
import { useFileUpload } from '../composables/useFileUpload.js';
|
||||
import { usePrefetchStore } from '../stores/prefetch';
|
||||
|
||||
const prefetchStore = usePrefetchStore();
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { isStoreOwner } = useAuth();
|
||||
const { uploadFile, removeHash, photoHashes } = useFileUpload({
|
||||
category: 'StoreMarket',
|
||||
maxSizeMB: 10
|
||||
});
|
||||
|
||||
const storeName = ref('');
|
||||
const description = ref('');
|
||||
const category = ref('');
|
||||
const subcategory = ref('');
|
||||
const categories = ref([]);
|
||||
const subcategories = ref([]);
|
||||
const address = ref('');
|
||||
const owner = ref('');
|
||||
const managers = ref([]);
|
||||
const cooperatives = ref([]);
|
||||
const cooperativeOptions = ref([]);
|
||||
const cooperativeSearch = ref('');
|
||||
const status = ref('active');
|
||||
const remarks = ref('');
|
||||
const loading = ref(false);
|
||||
const showSuccessState = ref(false);
|
||||
const showSuccessAnimation = ref(false);
|
||||
const error = ref(null);
|
||||
const successMessage = ref('');
|
||||
|
||||
|
||||
const users = ref([]);
|
||||
const userSearch = ref('');
|
||||
const filteredUsers = computed(() => {
|
||||
if (!userSearch.value) return users.value;
|
||||
return users.value.filter(u =>
|
||||
u.name.toLowerCase().includes(userSearch.value.toLowerCase()) ||
|
||||
u.username.toLowerCase().includes(userSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Store/New/Category/Datalist', {});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
categories.value = response.data.map(item => ({
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0]),
|
||||
value: typeof item === 'string' ? item : item[0]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubcategories = async () => {
|
||||
if (!category.value) {
|
||||
subcategories.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/Store/New/SubCategory/Datalist', {
|
||||
category: category.value
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
subcategories.value = response.data.map(item => ({
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0]),
|
||||
value: typeof item === 'string' ? item : item[0]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subcategories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = () => {
|
||||
subcategory.value = '';
|
||||
fetchSubcategories();
|
||||
};
|
||||
|
||||
const filteredCooperatives = computed(() => {
|
||||
if (!cooperativeSearch.value) return cooperativeOptions.value;
|
||||
const q = cooperativeSearch.value.toLowerCase();
|
||||
return cooperativeOptions.value.filter(c =>
|
||||
(c.name || '').toLowerCase().includes(q) ||
|
||||
(c.cooperative_type || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const fetchCooperatives = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Store/Cooperatives/List', {});
|
||||
if (response.data && response.data.success && Array.isArray(response.data.data)) {
|
||||
cooperativeOptions.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cooperatives:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
// STORE_OWNER may only see STORE_MANAGER descendants as eligible additional managers.
|
||||
const payload = isStoreOwner.value ? { type: 'store manager' } : {};
|
||||
const response = await axios.post('/admin/user/list/numbers/hash', payload);
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
users.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const hasManagerCandidates = computed(() => users.value.length > 0);
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
return !!(loading.value || successMessage.value || !storeName.value || !description.value || !address.value);
|
||||
});
|
||||
|
||||
const dropzoneRef = ref(null);
|
||||
const dropzoneFiles = ref([]);
|
||||
|
||||
// Watch for new files in dropzone and upload them
|
||||
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
|
||||
// Find files that are not yet uploading and don't have a hashkey
|
||||
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
|
||||
|
||||
for (const fileObj of filesToUpload) {
|
||||
const index = newFiles.indexOf(fileObj);
|
||||
if (index === -1) continue;
|
||||
|
||||
// Set uploading status
|
||||
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
|
||||
|
||||
const result = await uploadFile(fileObj.file);
|
||||
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
hashkey: result.hashkey
|
||||
});
|
||||
} else {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
error: 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
removeHash(hashkey);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null;
|
||||
successMessage.value = '';
|
||||
|
||||
if (!storeName.value || !description.value || !address.value) {
|
||||
error.value = 'Please fill in all required fields';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: storeName.value,
|
||||
description: description.value,
|
||||
address: address.value,
|
||||
category: category.value,
|
||||
subcategory: subcategory.value,
|
||||
managers: managers.value,
|
||||
photourl: dropzoneFiles.value
|
||||
.filter(f => f.hashkey)
|
||||
.map(f => f.hashkey),
|
||||
};
|
||||
|
||||
if (!isStoreOwner.value) {
|
||||
payload.owner = owner.value || undefined;
|
||||
payload.cooperatives = cooperatives.value;
|
||||
payload.status = status.value;
|
||||
payload.remarks = remarks.value;
|
||||
}
|
||||
|
||||
const response = await axios.post('/Store/New', payload);
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
showSuccessState.value = true;
|
||||
showSuccessAnimation.value = true;
|
||||
successMessage.value = 'Store created successfully!';
|
||||
|
||||
// Proactively prefetch stores list so ListStores shows fresh data immediately
|
||||
try {
|
||||
const listResp = await axios.post('/ListStores/List/data', {});
|
||||
if (listResp.data) {
|
||||
const fresh = listResp.data.props || listResp.data;
|
||||
prefetchStore.setCache('POST:/ListStores/List/data:{}', fresh);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to prefetch stores list:', e);
|
||||
}
|
||||
|
||||
const newStoreHash = response.data.hashkey;
|
||||
setTimeout(() => {
|
||||
if (newStoreHash) {
|
||||
navigate({ page: 'AddProductsToStore', props: { target: newStoreHash } });
|
||||
} else {
|
||||
navigate({ page: 'ListStores' });
|
||||
}
|
||||
}, 1500);
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to create store';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create store:', err);
|
||||
error.value = err.response?.data?.message || 'Failed to create store. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUsers();
|
||||
fetchCategories();
|
||||
if (!isStoreOwner.value) {
|
||||
fetchCooperatives();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-store-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Launch Your Store</h1>
|
||||
<p class="text-muted">Set up your marketplace presence in just a few clicks</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<div class="form-grid">
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Essential Information">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="storeName" class="form-label">Store Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="storeName"
|
||||
v-model="storeName"
|
||||
class="premium-input"
|
||||
placeholder="e.g., Organic Bounty Farm"
|
||||
autocomplete="off"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="category" class="form-label">Category</label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="category"
|
||||
class="premium-select"
|
||||
@change="handleCategoryChange"
|
||||
>
|
||||
<option value="" disabled>Select a category</option>
|
||||
<option v-for="cat in categories" :key="cat.value" :value="cat.value">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="subcategory" class="form-label">Subcategory</label>
|
||||
<select
|
||||
id="subcategory"
|
||||
v-model="subcategory"
|
||||
class="premium-select"
|
||||
:disabled="subcategories.length === 0"
|
||||
>
|
||||
<option value="" disabled>Select subcategory</option>
|
||||
<option v-for="sub in subcategories" :key="sub.value" :value="sub.value">
|
||||
{{ sub.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="description" class="form-label">Description <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
class="premium-input"
|
||||
rows="5"
|
||||
placeholder="Describe what makes your store special..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
|
||||
<label for="remarks" class="form-label">Internal Remarks</label>
|
||||
<textarea
|
||||
id="remarks"
|
||||
v-model="remarks"
|
||||
class="premium-input"
|
||||
rows="2"
|
||||
placeholder="Any internal notes or remarks..."
|
||||
></textarea>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Location & Photos -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Location & Visuals">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="address" class="form-label">Address <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="address"
|
||||
v-model="address"
|
||||
class="premium-input"
|
||||
rows="2"
|
||||
placeholder="Complete physical address"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="!isStoreOwner" class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="owner" class="form-label">Store Owner</label>
|
||||
<select
|
||||
id="owner"
|
||||
v-model="owner"
|
||||
class="premium-select"
|
||||
>
|
||||
<option value="" disabled>Select owner</option>
|
||||
<option v-for="user in users" :key="user.hashkey" :value="user.hashkey">
|
||||
{{ user.name }} ({{ user.username }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isStoreOwner" class="premium-input-group mb-4">
|
||||
<label class="form-label">Store Owner</label>
|
||||
<div class="premium-input" style="background:rgba(59,130,246,0.06); border-color:rgba(59,130,246,0.25);">
|
||||
<i class="fas fa-user-shield me-2 text-primary"></i>
|
||||
You will be the owner of this store.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Manager Selection -->
|
||||
<div v-if="!isStoreOwner || hasManagerCandidates" class="premium-input-group mb-4">
|
||||
<label class="form-label">Additional Store Managers</label>
|
||||
<div class="multi-user-list glass-card p-3">
|
||||
<div class="search-box mb-2">
|
||||
<input type="text" v-model="userSearch" placeholder="Search users..." class="search-input">
|
||||
</div>
|
||||
<div class="user-selection-area">
|
||||
<div v-for="user in filteredUsers" :key="user.hashkey" class="user-item">
|
||||
<label class="custom-checkbox-label">
|
||||
<input type="checkbox" v-model="managers" :value="user.hashkey" class="custom-checkbox">
|
||||
<span class="user-name">{{ user.name }}</span>
|
||||
<span class="user-meta">{{ user.username }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="input-hint mt-2">Select one or more users to help manage this store.</p>
|
||||
</div>
|
||||
|
||||
<!-- Cooperative Links (optional, many-to-many) -->
|
||||
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
|
||||
<label class="form-label">Linked Cooperatives</label>
|
||||
<div class="multi-user-list glass-card p-3">
|
||||
<div class="search-box mb-2">
|
||||
<input type="text" v-model="cooperativeSearch" placeholder="Search cooperatives..." class="search-input">
|
||||
</div>
|
||||
<div class="user-selection-area">
|
||||
<div v-if="filteredCooperatives.length === 0" class="text-muted small p-2">
|
||||
No cooperatives available.
|
||||
</div>
|
||||
<div v-for="coop in filteredCooperatives" :key="coop.hashkey" class="user-item">
|
||||
<label class="custom-checkbox-label">
|
||||
<input type="checkbox" v-model="cooperatives" :value="coop.hashkey" class="custom-checkbox">
|
||||
<span class="user-name">{{ coop.name }}</span>
|
||||
<span class="user-meta">{{ coop.cooperative_type || '' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="input-hint mt-2">Optional. Link this store to one or more cooperatives.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isStoreOwner" class="premium-input-group mb-4">
|
||||
<label for="status" class="form-label">Store Status</label>
|
||||
<select
|
||||
id="status"
|
||||
v-model="status"
|
||||
class="premium-select"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-2">
|
||||
<label class="form-label">Store Photos</label>
|
||||
<Dropzone
|
||||
ref="dropzoneRef"
|
||||
v-model:files="dropzoneFiles"
|
||||
@removed="handlePhotoRemoved"
|
||||
/>
|
||||
<p class="input-hint mt-2">Upload high-quality images to attract more customers.</p>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar mt-5 text-center">
|
||||
<AnimatedButton
|
||||
@click="handleSubmit"
|
||||
:disabled="isButtonDisabled"
|
||||
btnClass="btn-premium-launch"
|
||||
:loading="loading"
|
||||
:success="showSuccessState"
|
||||
>
|
||||
Create Store Now
|
||||
</AnimatedButton>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="navigate({ page: 'ListStores' })"
|
||||
class="btn-text"
|
||||
>
|
||||
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Animation Overlay -->
|
||||
<div v-if="showSuccessAnimation" class="success-overlay">
|
||||
<div class="text-center animate-bounce-in">
|
||||
<LottiePlayer
|
||||
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
|
||||
:loop="false"
|
||||
width="250px"
|
||||
height="250px"
|
||||
/>
|
||||
<h2 class="fw_8 mt-4 text-primary headline-gradient">Congratulations!</h2>
|
||||
<p class="text-muted">Your store is now ready for business.</p>
|
||||
</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;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.multi-user-list {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
.user-item {
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.custom-checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.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.9rem;
|
||||
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-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;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 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(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-premium-launch:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
padding: 12px 48px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.success-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(15px);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .success-overlay {
|
||||
background: rgba(18, 20, 24, 0.98);
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
@keyframes bounce-in {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
50% { transform: scale(1.05); opacity: 1; }
|
||||
70% { transform: scale(0.9); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.headline-gradient {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,617 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Edit Product Ultimate');
|
||||
|
||||
import { ref, onMounted, watch, computed } 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'
|
||||
import Dropzone from '../Components/Core/Dropzone.vue'
|
||||
import { useFileUpload } from '../composables/useFileUpload.js'
|
||||
import { useProductStore } from '../stores/product'
|
||||
|
||||
const productStore = useProductStore()
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, default: null },
|
||||
payload: { type: Object, default: null }
|
||||
})
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
const { uploadFile, removeHash, photoHashes, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
|
||||
category: 'ProductMarket',
|
||||
maxSizeMB: 10
|
||||
})
|
||||
|
||||
// Form state
|
||||
const productId = ref(null)
|
||||
const productName = ref('')
|
||||
const productDescription = ref('')
|
||||
const productCategory = ref('')
|
||||
const productSubcategory = ref('')
|
||||
const productPrice = ref(0)
|
||||
const productUnitName = ref('')
|
||||
const productAvailable = ref(0)
|
||||
const productBarcode = ref('')
|
||||
const storeHash = ref(null)
|
||||
|
||||
// Data lists
|
||||
const categoryList = ref([])
|
||||
const subcategoryList = ref([])
|
||||
|
||||
// Loading state
|
||||
const isLoading = ref(false)
|
||||
const successMessage = ref('')
|
||||
const error = ref(null)
|
||||
|
||||
// Initialize component
|
||||
onMounted(async () => {
|
||||
document.title = 'Edit Product'
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
storeHash.value = urlParams.get('store_hash') || urlParams.get('store')
|
||||
await loadCategories()
|
||||
await loadProductData()
|
||||
})
|
||||
|
||||
// Load categories
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Products/New/Category/Datalist', {})
|
||||
const data = response.data.categories || response.data
|
||||
if (data && Array.isArray(data)) {
|
||||
categoryList.value = data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading categories:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load subcategories when category changes
|
||||
const loadSubcategories = async () => {
|
||||
if (!productCategory.value) {
|
||||
subcategoryList.value = []
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/Products/New/SubCategory/Datalist', {
|
||||
category: productCategory.value
|
||||
})
|
||||
const data = response.data.subcategories || response.data
|
||||
if (data && Array.isArray(data)) {
|
||||
subcategoryList.value = data.map(item => ({
|
||||
value: typeof item === 'string' ? item : item[0],
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0])
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading subcategories:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Load product data
|
||||
const loadProductData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Extract info from props first, then from URL
|
||||
if (props.payload) {
|
||||
productId.value = props.payload.product_hashkey || props.payload.product_hash || props.payload.target;
|
||||
storeHash.value = props.payload.store_hashkey || props.payload.store_hash;
|
||||
} else {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
productId.value = props.target || urlParams.get('product_id') || urlParams.get('id') || urlParams.get('hashkey')
|
||||
storeHash.value = urlParams.get('store_hash') || urlParams.get('store')
|
||||
}
|
||||
|
||||
if (!productId.value) {
|
||||
error.value = 'Product ID not found'
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.post('/View/Product/Details/data', {
|
||||
target: productId.value,
|
||||
data: {
|
||||
product_id: productId.value,
|
||||
store_hash: storeHash.value
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
const product = response.data.data
|
||||
productName.value = product.name || ''
|
||||
productDescription.value = product.description || ''
|
||||
productCategory.value = product.category || ''
|
||||
productSubcategory.value = product.subcategory || ''
|
||||
productPrice.value = product.price || 0
|
||||
productUnitName.value = product.unitname || ''
|
||||
productBarcode.value = product.barcode || ''
|
||||
productAvailable.value = product.available || 0
|
||||
|
||||
// Load subcategories for the initial category
|
||||
if (productCategory.value) {
|
||||
await loadSubcategories()
|
||||
productSubcategory.value = product.subcategory || ''
|
||||
}
|
||||
|
||||
// Handle photos
|
||||
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
|
||||
const initialFiles = product.photourlDropzone.map(f => ({
|
||||
file: { name: f.name || 'Image' },
|
||||
hashkey: f.hashkey,
|
||||
progress: 100,
|
||||
uploading: false,
|
||||
preview: f.url
|
||||
}));
|
||||
dropzoneFiles.value = initialFiles;
|
||||
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading product data:', err)
|
||||
error.value = 'Failed to load product data'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Dropzone handling
|
||||
const dropzoneRef = ref(null)
|
||||
const dropzoneFiles = ref([])
|
||||
|
||||
// Watch for new files in dropzone and upload them
|
||||
watch(() => dropzoneFiles.value, async (newFiles, oldFiles) => {
|
||||
// Find files that are not yet uploading and don't have a hashkey
|
||||
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
|
||||
|
||||
for (const fileObj of filesToUpload) {
|
||||
const index = newFiles.indexOf(fileObj);
|
||||
if (index === -1) continue;
|
||||
|
||||
// Set uploading status
|
||||
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
|
||||
|
||||
const result = await uploadFile(fileObj.file);
|
||||
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
hashkey: result.hashkey
|
||||
});
|
||||
} else {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
error: 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
removeHash(hashkey);
|
||||
};
|
||||
|
||||
const handleCategoryChange = () => {
|
||||
loadSubcategories()
|
||||
}
|
||||
|
||||
const validateForm = () => {
|
||||
if (!productName.value) {
|
||||
error.value = 'Product name is required'
|
||||
return false
|
||||
}
|
||||
if (!productCategory.value) {
|
||||
error.value = 'Category is required'
|
||||
return false
|
||||
}
|
||||
if (productPrice.value === null || productPrice.value === undefined || productPrice.value < 0) {
|
||||
error.value = 'Valid price is required'
|
||||
return false
|
||||
}
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await axios.post('/Products/Admin/Edit/', {
|
||||
target: productId.value,
|
||||
data: {
|
||||
store_hash: storeHash.value
|
||||
},
|
||||
EditProductName: productName.value,
|
||||
EditProductDescription: productDescription.value,
|
||||
EditProductCategory: productCategory.value,
|
||||
EditProductSubCategory: productSubcategory.value,
|
||||
EditProductPrice: parseFloat(productPrice.value),
|
||||
EditProductUnitName: productUnitName.value,
|
||||
EditProductAvailable: parseInt(productAvailable.value),
|
||||
EditProductBarcode: productBarcode.value,
|
||||
status: true, // Assuming active if editing, can be bound to a checkbox if needed
|
||||
photourl: dropzoneFiles.value
|
||||
.filter(f => f.hashkey)
|
||||
.map(f => f.hashkey)
|
||||
})
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
successMessage.value = 'Product updated successfully!'
|
||||
|
||||
// Proactively prefetch products list
|
||||
productStore.fetchProducts()
|
||||
|
||||
setTimeout(() => {
|
||||
navigate({ page: 'ManageProductsAdmin' })
|
||||
}, 1500)
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to update product'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error updating product:', err)
|
||||
error.value = err.response?.data?.message || err.message || 'Failed to update product'
|
||||
|
||||
// Scroll to error if it occurs
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit-product-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Edit Product</h1>
|
||||
<p class="text-muted">Update your product details and availability</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<div class="form-grid" v-if="!isLoading || productName">
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Product Details">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="productName" class="form-label">Product Name <span class="required">*</span></label>
|
||||
<input
|
||||
type="text"
|
||||
id="productName"
|
||||
v-model="productName"
|
||||
class="premium-input"
|
||||
placeholder="Product Name"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="productDescription" class="form-label">Description</label>
|
||||
<textarea
|
||||
id="productDescription"
|
||||
v-model="productDescription"
|
||||
class="premium-input"
|
||||
rows="4"
|
||||
placeholder="Description..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="category" class="form-label">Category <span class="required">*</span></label>
|
||||
<select
|
||||
id="category"
|
||||
v-model="productCategory"
|
||||
class="premium-select"
|
||||
@change="handleCategoryChange"
|
||||
>
|
||||
<option value="" disabled>Select Category</option>
|
||||
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">
|
||||
{{ cat.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="subcategory" class="form-label">Subcategory</label>
|
||||
<select
|
||||
id="subcategory"
|
||||
v-model="productSubcategory"
|
||||
class="premium-select"
|
||||
:disabled="subcategoryList.length === 0"
|
||||
>
|
||||
<option value="" disabled>Select Subcategory</option>
|
||||
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">
|
||||
{{ sub.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Inventory & Photos -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Inventory & Pricing">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="price" class="form-label">Price (PHP) <span class="required">*</span></label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
v-model="productPrice"
|
||||
class="premium-input"
|
||||
step="0.01"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="unit" class="form-label">Unit</label>
|
||||
<input
|
||||
type="text"
|
||||
id="unit"
|
||||
v-model="productUnitName"
|
||||
class="premium-input"
|
||||
placeholder="e.g., 25kg"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="available" class="form-label">Available Stock</label>
|
||||
<input
|
||||
type="number"
|
||||
id="available"
|
||||
v-model="productAvailable"
|
||||
class="premium-input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="premium-input-group mb-4">
|
||||
<label for="barcode" class="form-label">Barcode</label>
|
||||
<input
|
||||
type="text"
|
||||
id="barcode"
|
||||
v-model="productBarcode"
|
||||
class="premium-input"
|
||||
maxlength="12"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="premium-input-group mb-2">
|
||||
<label class="form-label">Product Photos</label>
|
||||
<Dropzone
|
||||
ref="dropzoneRef"
|
||||
v-model:files="dropzoneFiles"
|
||||
@removed="handlePhotoRemoved"
|
||||
/>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-2">Loading product details...</p>
|
||||
</div>
|
||||
|
||||
<div class="action-bar mt-5 text-center">
|
||||
<button
|
||||
@click="handleSubmit"
|
||||
:disabled="isLoading || successMessage || isFileUploading"
|
||||
class="btn-premium-launch"
|
||||
:class="{ 'btn-loading': isLoading }"
|
||||
>
|
||||
<span v-if="!isLoading">Update Product</span>
|
||||
<LoadingSpinner v-else size="small" color="white" />
|
||||
</button>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
@click="navigate({ page: 'ManageProductsAdmin' })"
|
||||
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;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.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.9rem;
|
||||
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-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.btn-premium-launch {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 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(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-premium-launch:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 20px 25px -5px rgba(37, 99, 235, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-premium-launch:disabled {
|
||||
background: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-loading {
|
||||
padding: 12px 48px;
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-input, :global(.dark-mode) .premium-select {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .premium-title {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,500 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Edit Store Ultimate');
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate.js';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import CardSimple from '../Components/Core/CardSimple.vue';
|
||||
import Dropzone from '../Components/Core/Dropzone.vue';
|
||||
import InputGroup from '../Components/Core/Forms/InputGroup.vue';
|
||||
import InputGroupSelect from '../Components/Core/Forms/InputGroupSelect.vue';
|
||||
import InputGroupButton from '../Components/Core/Forms/InputGroupButton.vue';
|
||||
import InputGroupTextarea from '../Components/Core/Forms/InputGroupTextarea.vue';
|
||||
import InputGroupCheckbox from '../Components/Core/Forms/InputGroupCheckbox.vue';
|
||||
import { useFileUpload } from '../composables/useFileUpload.js';
|
||||
import { usePrefetchStore } from '../stores/prefetch';
|
||||
|
||||
const prefetchStore = usePrefetchStore();
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, default: null }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { uploadFile, removeHash, photoHashes, setInitialHashes } = useFileUpload({
|
||||
category: 'StoreMarket',
|
||||
maxSizeMB: 10
|
||||
});
|
||||
|
||||
const storeId = ref(null);
|
||||
const storeName = ref('');
|
||||
const description = ref('');
|
||||
const category = ref('');
|
||||
const subcategory = ref('');
|
||||
const subcategories = ref([]);
|
||||
const address = ref('');
|
||||
const remarks = ref('');
|
||||
const owner = ref('');
|
||||
const status = ref('active');
|
||||
const managers = ref([]);
|
||||
const cooperatives = ref([]);
|
||||
const cooperativeOptions = ref([]);
|
||||
const cooperativeSearch = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const successMessage = ref('');
|
||||
|
||||
const users = ref([]);
|
||||
const userSearch = ref('');
|
||||
const categories = ref([]);
|
||||
const filteredUsers = computed(() => {
|
||||
if (!userSearch.value) return users.value;
|
||||
return users.value.filter(u =>
|
||||
u.name.toLowerCase().includes(userSearch.value.toLowerCase()) ||
|
||||
u.username.toLowerCase().includes(userSearch.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredCooperatives = computed(() => {
|
||||
if (!cooperativeSearch.value) return cooperativeOptions.value;
|
||||
const q = cooperativeSearch.value.toLowerCase();
|
||||
return cooperativeOptions.value.filter(c =>
|
||||
(c.name || '').toLowerCase().includes(q) ||
|
||||
(c.cooperative_type || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const fetchCooperatives = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Store/Cooperatives/List', {});
|
||||
if (response.data && response.data.success && Array.isArray(response.data.data)) {
|
||||
cooperativeOptions.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cooperatives:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await axios.post('/admin/user/list/numbers/hash', {});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
users.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Store/New/Category/Datalist', {});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
categories.value = response.data.map(item => ({
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0]),
|
||||
value: typeof item === 'string' ? item : item[0]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSubcategories = async () => {
|
||||
if (!category.value) {
|
||||
subcategories.value = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/Store/New/SubCategory/Datalist', {
|
||||
category: category.value
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
subcategories.value = response.data.map(item => ({
|
||||
label: typeof item === 'string' ? item : (item[1] || item[0]),
|
||||
value: typeof item === 'string' ? item : item[0]
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subcategories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCategoryChange = () => {
|
||||
subcategory.value = '';
|
||||
fetchSubcategories();
|
||||
};
|
||||
|
||||
const fetchStoreData = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const hashkey = props.target || urlParams.get('hashkey') || urlParams.get('id');
|
||||
|
||||
if (!hashkey) {
|
||||
error.value = 'Store ID is missing';
|
||||
return;
|
||||
}
|
||||
|
||||
storeId.value = hashkey;
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Edit/Store/Details/data', { target: hashkey });
|
||||
if (response.data) {
|
||||
const data = response.data;
|
||||
storeName.value = data.name || '';
|
||||
description.value = data.description || '';
|
||||
category.value = data.category || '';
|
||||
subcategory.value = data.subcategory || '';
|
||||
address.value = data.address || '';
|
||||
owner.value = data.owner_hashkey || '';
|
||||
status.value = data.status || 'active';
|
||||
remarks.value = data.remarks || '';
|
||||
managers.value = data.managers_hashkeys || [];
|
||||
cooperatives.value = data.cooperative_hashkeys || [];
|
||||
|
||||
// Load subcategories for initial category
|
||||
if (category.value) {
|
||||
await fetchSubcategories();
|
||||
subcategory.value = data.subcategory || '';
|
||||
}
|
||||
|
||||
// Set initial photos in dropzone
|
||||
if (data.photourlDropzone && Array.isArray(data.photourlDropzone)) {
|
||||
// Dropzone component handles initial files via v-model:files
|
||||
const initialFiles = data.photourlDropzone.map(f => ({
|
||||
file: { name: f.name || 'Image' },
|
||||
hashkey: f.hashkey,
|
||||
progress: 100,
|
||||
uploading: false,
|
||||
preview: f.url // Assuming url is provided for preview
|
||||
}));
|
||||
dropzoneFiles.value = initialFiles;
|
||||
|
||||
// Also update the file upload composable state
|
||||
setInitialHashes(data.photourlDropzone.map(f => f.hashkey));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch store data:', err);
|
||||
error.value = 'Failed to load store data';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const isButtonDisabled = computed(() => {
|
||||
return !!(loading.value || successMessage.value || !storeName.value || !description.value || !address.value);
|
||||
});
|
||||
|
||||
const dropzoneRef = ref(null);
|
||||
const dropzoneFiles = ref([]);
|
||||
|
||||
// Logic moved to Dropzone component and its v-model
|
||||
|
||||
// Watch for new files in dropzone and upload them
|
||||
watch(() => dropzoneFiles.value, async (newFiles) => {
|
||||
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
|
||||
|
||||
for (const fileObj of filesToUpload) {
|
||||
const index = newFiles.indexOf(fileObj);
|
||||
if (index === -1) continue;
|
||||
|
||||
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
|
||||
|
||||
const result = await uploadFile(fileObj.file);
|
||||
|
||||
if (result && result.hashkey) {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 100,
|
||||
hashkey: result.hashkey
|
||||
});
|
||||
} else {
|
||||
dropzoneRef.value.setFileStatus(index, {
|
||||
uploading: false,
|
||||
progress: 0,
|
||||
error: 'Upload failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
const handlePhotoRemoved = (hashkey) => {
|
||||
removeHash(hashkey);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = null;
|
||||
successMessage.value = '';
|
||||
|
||||
if (!storeName.value || !description.value || !address.value) {
|
||||
error.value = 'Please fill in all required fields';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Store/Edit', {
|
||||
target: storeId.value,
|
||||
name: storeName.value,
|
||||
description: description.value,
|
||||
address: address.value,
|
||||
category: category.value,
|
||||
subcategory: subcategory.value,
|
||||
owner: owner.value || undefined,
|
||||
managers: managers.value,
|
||||
cooperatives: cooperatives.value,
|
||||
status: status.value,
|
||||
remarks: remarks.value,
|
||||
photourl: dropzoneFiles.value
|
||||
.filter(f => f.hashkey)
|
||||
.map(f => f.hashkey)
|
||||
});
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
successMessage.value = 'Store updated successfully!';
|
||||
|
||||
// Proactively prefetch stores list so ListStores shows fresh data immediately
|
||||
try {
|
||||
const listResp = await axios.post('/ListStores/List/data', {});
|
||||
if (listResp.data) {
|
||||
const fresh = listResp.data.props || listResp.data;
|
||||
prefetchStore.setCache('POST:/ListStores/List/data:{}', fresh);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to prefetch stores list:', e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
navigate({ page: 'ViewStoreMarket', props: { target: storeId.value } });
|
||||
}, 1500);
|
||||
} else {
|
||||
error.value = response.data?.message || 'Failed to update store';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update store:', err);
|
||||
error.value = err.response?.data?.message || 'Failed to update store. Please try again.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
fetchUsers(),
|
||||
fetchCategories(),
|
||||
fetchCooperatives(),
|
||||
fetchStoreData()
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit-store-page pb-5">
|
||||
<div class="tf-container mt-5 mb-4 text-center">
|
||||
<h1 class="fw_8 premium-title">Edit Your Store</h1>
|
||||
<p class="text-muted">Update your store information and visuals</p>
|
||||
</div>
|
||||
|
||||
<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="error" class="tf-container mb-4">
|
||||
<div class="glass-alert alert-danger animate-shake">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<div class="form-grid">
|
||||
<!-- Left Column: Basic Info -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Essential Information">
|
||||
<InputGroup
|
||||
id="storeName"
|
||||
label="Store Name"
|
||||
v-model="storeName"
|
||||
required
|
||||
placeholder="Enter store name"
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<InputGroupSelect
|
||||
id="category"
|
||||
label="Category"
|
||||
v-model="category"
|
||||
:options="categories.map(c => ({ value: c.value, text: c.label }))"
|
||||
placeholder="Select a category"
|
||||
@update:modelValue="handleCategoryChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<InputGroupSelect
|
||||
id="subcategory"
|
||||
label="Subcategory"
|
||||
v-model="subcategory"
|
||||
:options="subcategories.map(s => ({ value: s.value, text: s.label }))"
|
||||
placeholder="Select subcategory"
|
||||
:disabled="subcategories.length === 0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputGroupTextarea
|
||||
id="description"
|
||||
label="Description"
|
||||
v-model="description"
|
||||
required
|
||||
:rows="5"
|
||||
placeholder="Store description..."
|
||||
/>
|
||||
|
||||
<InputGroupTextarea
|
||||
id="remarks"
|
||||
label="Internal Remarks"
|
||||
v-model="remarks"
|
||||
:rows="2"
|
||||
placeholder="Any internal notes or remarks..."
|
||||
/>
|
||||
</CardSimple>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Location & Photos -->
|
||||
<div class="form-section">
|
||||
<CardSimple title="Location & Visuals">
|
||||
<InputGroupTextarea
|
||||
id="address"
|
||||
label="Address"
|
||||
v-model="address"
|
||||
required
|
||||
:rows="2"
|
||||
placeholder="Store address"
|
||||
/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<InputGroupSelect
|
||||
id="owner"
|
||||
label="Store Owner"
|
||||
v-model="owner"
|
||||
:options="users.map(u => ({ value: u.hashkey, text: `${u.name} (${u.username})` }))"
|
||||
placeholder="Select owner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Manager Selection -->
|
||||
<div class="premium-input-group mb-4">
|
||||
<label class="form-label">Additional Store Managers</label>
|
||||
<div class="multi-user-list glass-card p-3">
|
||||
<div class="search-box mb-2">
|
||||
<InputGroup
|
||||
id="userSearch"
|
||||
v-model="userSearch"
|
||||
placeholder="Search users..."
|
||||
variant="soft"
|
||||
:isPremium="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-selection-area">
|
||||
<div v-for="user in filteredUsers" :key="user.hashkey" class="user-item">
|
||||
<InputGroupCheckbox
|
||||
:id="`manager-${user.hashkey}`"
|
||||
:label="`${user.name} (${user.username})`"
|
||||
v-model="managers"
|
||||
:value="user.hashkey"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="input-hint mt-2">Select one or more users to help manage this store.</p>
|
||||
</div>
|
||||
|
||||
<!-- Cooperative Links (optional, many-to-many) -->
|
||||
<div class="premium-input-group mb-4">
|
||||
<label class="form-label">Linked Cooperatives</label>
|
||||
<div class="multi-user-list glass-card p-3">
|
||||
<div class="search-box mb-2">
|
||||
<InputGroup
|
||||
id="cooperativeSearch"
|
||||
v-model="cooperativeSearch"
|
||||
placeholder="Search cooperatives..."
|
||||
variant="soft"
|
||||
:isPremium="false"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-selection-area">
|
||||
<div v-if="filteredCooperatives.length === 0" class="text-muted small p-2">
|
||||
No cooperatives available.
|
||||
</div>
|
||||
<div v-for="coop in filteredCooperatives" :key="coop.hashkey" class="user-item">
|
||||
<InputGroupCheckbox
|
||||
:id="`coop-${coop.hashkey}`"
|
||||
:label="`${coop.name}${coop.cooperative_type ? ' — ' + coop.cooperative_type : ''}`"
|
||||
v-model="cooperatives"
|
||||
:value="coop.hashkey"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="input-hint mt-2">Optional. Link this store to one or more cooperatives.</p>
|
||||
</div>
|
||||
|
||||
<InputGroupSelect
|
||||
id="status"
|
||||
label="Store Status"
|
||||
v-model="status"
|
||||
:options="[
|
||||
{ value: 'active', text: 'Active' },
|
||||
{ value: 'inactive', text: 'Inactive' },
|
||||
{ value: 'pending', text: 'Pending' },
|
||||
{ value: 'suspended', text: 'Suspended' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="premium-input-group mb-2">
|
||||
<label class="form-label">Store Photos</label>
|
||||
<Dropzone
|
||||
ref="dropzoneRef"
|
||||
v-model:files="dropzoneFiles"
|
||||
@removed="handlePhotoRemoved"
|
||||
/>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-bar mt-5 text-center d-flex flex-column align-items-center gap-3">
|
||||
<InputGroupButton
|
||||
text="Update Store"
|
||||
:loading="loading"
|
||||
:disabled="isButtonDisabled"
|
||||
size="lg"
|
||||
variant="primary"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
|
||||
<InputGroupButton
|
||||
text="Cancel and Return"
|
||||
variant="text"
|
||||
@click="navigate({ page: 'ViewStoreMarket', props: { target: storeId } })"
|
||||
>
|
||||
<i class="fas fa-chevron-left me-2"></i> Cancel and Return
|
||||
</InputGroupButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.dark-mode) .form-label {
|
||||
color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
@@ -1,304 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import { usePageTitle } from "../composables/Core/usePageTitle";
|
||||
import { useNavigate } from "../composables/Core/useNavigate";
|
||||
import { useModal } from "../composables/Core/useModal";
|
||||
|
||||
usePageTitle("Enroll Farmer");
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const props = defineProps({
|
||||
target: String,
|
||||
});
|
||||
|
||||
// Cooperative
|
||||
const cooperative = ref(null);
|
||||
const loadingCoop = ref(true);
|
||||
|
||||
// Search
|
||||
const searchQuery = ref("");
|
||||
const searchResults = ref([]);
|
||||
const isSearching = ref(false);
|
||||
|
||||
// Selected User
|
||||
const selectedUser = ref(null);
|
||||
|
||||
// Form
|
||||
const form = ref({
|
||||
farm_name: "",
|
||||
farm_location: "",
|
||||
main_crops: [],
|
||||
});
|
||||
const cropInput = ref("");
|
||||
const isSaving = ref(false);
|
||||
|
||||
const fetchCooperative = async () => {
|
||||
if (!props.target) return;
|
||||
loadingCoop.value = true;
|
||||
try {
|
||||
const response = await axios.post("/Cooperatives/Get", {
|
||||
hashkey: props.target,
|
||||
});
|
||||
if (response.data.success) {
|
||||
cooperative.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch cooperative", error);
|
||||
} finally {
|
||||
loadingCoop.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (searchQuery.value.length < 2) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const response = await axios.post("/Farmers/List", {
|
||||
search: searchQuery.value,
|
||||
});
|
||||
if (response.data.success) {
|
||||
searchResults.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Search failed", error);
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectUser = (user) => {
|
||||
selectedUser.value = user;
|
||||
searchResults.value = [];
|
||||
searchQuery.value = "";
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedUser.value = null;
|
||||
};
|
||||
|
||||
const addCrop = () => {
|
||||
if (
|
||||
cropInput.value &&
|
||||
!form.value.main_crops.includes(cropInput.value)
|
||||
) {
|
||||
form.value.main_crops.push(cropInput.value);
|
||||
cropInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const removeCrop = (crop) => {
|
||||
form.value.main_crops = form.value.main_crops.filter((c) => c !== crop);
|
||||
};
|
||||
|
||||
const enrollFarmer = async () => {
|
||||
if (!selectedUser.value) {
|
||||
modal.open({ title: "Error", body: "Please select a user first." });
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const response = await axios.post("/Farmers/Register", {
|
||||
...form.value,
|
||||
organization_hash: props.target,
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
modal.open({
|
||||
title: "Success",
|
||||
body: "Farmer enrolled successfully!",
|
||||
onClose: () =>
|
||||
navigate({ page: "CooperativeDetail", target: props.target }),
|
||||
});
|
||||
} else {
|
||||
modal.open({
|
||||
title: "Error",
|
||||
body: response.data.message || "Enrollment failed. Please try again.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Enrollment failed", error);
|
||||
modal.open({
|
||||
title: "Error",
|
||||
body:
|
||||
error.response?.data?.message ||
|
||||
"Failed to enroll farmer. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchCooperative);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="enroll-farmer pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="mb-4">
|
||||
<button
|
||||
@click="navigate({ page: 'CooperativeDetail', target: target })"
|
||||
class="btn btn-link text-decoration-none p-0 mb-2 text-primary"
|
||||
>
|
||||
<i class="fas fa-arrow-left me-1"></i> Back to Cooperative
|
||||
</button>
|
||||
<h3 class="fw_6 mb-0">Enroll Farmer</h3>
|
||||
<p v-if="cooperative" class="text-muted">
|
||||
Adding farmer to <strong>{{ cooperative.name }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingCoop" class="text-center py-5">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-primary"></i>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- User Selection -->
|
||||
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4">
|
||||
<h5 class="fw_6 mb-3">1. Select User</h5>
|
||||
|
||||
<div v-if="!selectedUser">
|
||||
<div class="position-relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searchUsers"
|
||||
type="text"
|
||||
class="form-control rounded-pill"
|
||||
placeholder="Search user by name or mobile..."
|
||||
/>
|
||||
<div
|
||||
v-if="searchResults.length > 0"
|
||||
class="search-results card border-0 shadow-sm mt-2 position-absolute w-100 z-1"
|
||||
>
|
||||
<div
|
||||
v-for="user in searchResults"
|
||||
:key="user.hashkey"
|
||||
@click="selectUser(user)"
|
||||
class="p-3 border-bottom cursor-pointer hover-bg"
|
||||
>
|
||||
<div class="fw-bold">
|
||||
{{ user.fullname || user.firstname + " " + user.lastname }}
|
||||
</div>
|
||||
<small class="text-muted">{{ user.mobile }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSearching" class="text-center mt-2">
|
||||
<small class="text-muted"
|
||||
><i class="fas fa-spinner fa-spin me-1"></i> Searching...</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="d-flex align-items-center justify-content-between bg-light p-3 rounded-15">
|
||||
<div>
|
||||
<div class="fw-bold">
|
||||
{{
|
||||
selectedUser.fullname ||
|
||||
selectedUser.firstname + " " + selectedUser.lastname
|
||||
}}
|
||||
</div>
|
||||
<small class="text-muted">{{ selectedUser.mobile }}</small>
|
||||
</div>
|
||||
<button @click="clearSelection" class="btn btn-sm btn-outline-danger rounded-pill">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Farmer Details -->
|
||||
<div class="card border-0 shadow-sm rounded-20 p-4">
|
||||
<h5 class="fw_6 mb-3">2. Farm Details</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Farm Name</label>
|
||||
<input
|
||||
v-model="form.farm_name"
|
||||
type="text"
|
||||
class="form-control rounded-pill"
|
||||
placeholder="Enter farm name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Farm Location</label>
|
||||
<textarea
|
||||
v-model="form.farm_location"
|
||||
class="form-control rounded-15"
|
||||
rows="2"
|
||||
placeholder="Barangay, Municipality, Province"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">Main Crops</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input
|
||||
v-model="cropInput"
|
||||
@keyup.enter="addCrop"
|
||||
type="text"
|
||||
class="form-control rounded-pill"
|
||||
placeholder="e.g. Rice, Corn"
|
||||
/>
|
||||
<button
|
||||
@click="addCrop"
|
||||
type="button"
|
||||
class="btn btn-primary rounded-pill px-4"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="crop in form.main_crops"
|
||||
:key="crop"
|
||||
class="badge bg-light text-dark rounded-pill border px-3 py-2"
|
||||
>
|
||||
{{ crop }}
|
||||
<i
|
||||
@click="removeCrop(crop)"
|
||||
class="fas fa-times ms-2 cursor-pointer text-danger"
|
||||
></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isSaving || !selectedUser"
|
||||
@click="enrollFarmer"
|
||||
class="btn btn-primary w-100 rounded-pill py-3 fw-bold"
|
||||
>
|
||||
<span v-if="isSaving"
|
||||
><i class="fas fa-spinner fa-spin me-2"></i> Enrolling...</span
|
||||
>
|
||||
<span v-else>Enroll Farmer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 {
|
||||
border-radius: 20px;
|
||||
}
|
||||
.rounded-15 {
|
||||
border-radius: 15px;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.hover-bg:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.search-results {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
|
||||
usePageTitle('Farmer Profile');
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const form = ref({
|
||||
farm_name: '',
|
||||
farm_location: '',
|
||||
organization_hash: '',
|
||||
main_crops: []
|
||||
});
|
||||
|
||||
const organizations = ref([]);
|
||||
const loading = ref(false);
|
||||
const cropInput = ref('');
|
||||
|
||||
const fetchOrganizations = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Organizations/List');
|
||||
if (response.data.success) {
|
||||
organizations.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organizations');
|
||||
}
|
||||
};
|
||||
|
||||
const addCrop = () => {
|
||||
if (cropInput.value && !form.value.main_crops.includes(cropInput.value)) {
|
||||
form.value.main_crops.push(cropInput.value);
|
||||
cropInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Farmers/Register', form.value);
|
||||
if (response.data.success) {
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: 'Profile submitted for verification!',
|
||||
onClose: () => navigate({ page: 'Home' })
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to register. Please try again.'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchOrganizations);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="farmer-profile-edit pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<BackButton to="Home" />
|
||||
|
||||
<h3 class="fw_6 mb-4">Farmer Registration</h3>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-20 p-4 mb-4">
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Farm Name</label>
|
||||
<input v-model="form.farm_name" type="text" class="form-control rounded-pill" required placeholder="Enter farm name">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Farm Location</label>
|
||||
<textarea v-model="form.farm_location" class="form-control rounded-15" rows="2" placeholder="Barangay, Municipality, Province"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Cooperative/Organization</label>
|
||||
<select v-model="form.organization_hash" class="form-select rounded-pill">
|
||||
<option value="">None / Independent</option>
|
||||
<option v-for="org in organizations" :key="org.hashkey" :value="org.hashkey">
|
||||
{{ org.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">Main Crops</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input v-model="cropInput" @keyup.enter="addCrop" type="text" class="form-control rounded-pill" placeholder="e.g. Rice, Corn">
|
||||
<button @click="addCrop" type="button" class="btn btn-primary rounded-pill px-4">Add</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span v-for="crop in form.main_crops" :key="crop" class="badge bg-light text-dark rounded-pill border px-3 py-2">
|
||||
{{ crop }}
|
||||
<i @click="form.main_crops = form.main_crops.filter(c => c !== crop)" class="fas fa-times ms-2 cursor-pointer text-danger"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading" type="submit" class="btn btn-primary w-100 rounded-pill py-3 fw-bold">
|
||||
<span v-if="loading"><i class="fas fa-spinner fa-spin me-2"></i> Submitting...</span>
|
||||
<span v-else>Submit for Verification</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
</style>
|
||||
@@ -1,196 +1,104 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useAuth } from '../composables/Core/useAuth.js';
|
||||
import { useUIStore } from '../stores/ui';
|
||||
import { useUserStore } from '../stores/user.js';
|
||||
import HomeUltimate from './Fragments/Home/HomeUltimate.vue';
|
||||
import HomePublic from './Fragments/Home/HomePublic.vue';
|
||||
import HomeSuperOperator from './Fragments/Home/HomeSuperOperator.vue';
|
||||
import HomeOperator from './Fragments/Home/HomeOperator.vue';
|
||||
import HomeStoreOwner from './Fragments/Home/HomeStoreOwner.vue';
|
||||
import HomeStoreManager from './Fragments/Home/HomeStoreManager.vue';
|
||||
import HomeShared from './Fragments/Home/HomeShared.vue';
|
||||
import HomeCooperative from './Fragments/Home/HomeCooperative.vue';
|
||||
import HomeCoopOfficer from './Fragments/Home/HomeCoopOfficer.vue';
|
||||
import HomeCoopMember from './Fragments/Home/HomeCoopMember.vue';
|
||||
import { executeRequest } from '../utils/executeRequest.js';
|
||||
import { navigate } from '../utils/navigate.js';
|
||||
|
||||
const uiStore = useUIStore();
|
||||
const userStore = useUserStore();
|
||||
usePageTitle(uiStore.appName);
|
||||
usePageTitle(uiStore.appName ?? 'Barangay System');
|
||||
|
||||
const {
|
||||
isUltimate, isPublic, isSuperOperator, isOperator,
|
||||
isCoordinator, isStoreOwner, isStoreManager,
|
||||
isSupplier, isSupplierOverseer, isRider,
|
||||
isUser, isPOSTerminal, role, user,
|
||||
isCoopOfficer, isCoopMember
|
||||
} = useAuth();
|
||||
const { isSuperAdmin, isPunongBarangay, isAdmin, isBarangayStaff, isResident, role } = useAuth();
|
||||
|
||||
const stats = ref({});
|
||||
const loading = ref(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await executeRequest('/home-data');
|
||||
if (res?.props?.stats) stats.value = res.props.stats;
|
||||
} catch (e) { /* silent */ }
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const adminCards = [
|
||||
{ label: 'Residents', icon: '👥', route: '/Barangay/ManageResidents', color: 'bg-blue-500' },
|
||||
{ label: 'Households', icon: '🏠', route: '/Barangay/ManageHouseholds', color: 'bg-green-500' },
|
||||
{ label: 'Blotters', icon: '⚖️', route: '/Barangay/ManageBlotters', color: 'bg-orange-500' },
|
||||
{ label: 'Documents', icon: '📋', route: '/Barangay/ManageDocumentRequests', color: 'bg-purple-500' },
|
||||
{ label: 'Projects', icon: '🏗️', route: '/Barangay/ManageProjects', color: 'bg-indigo-500' },
|
||||
{ label: 'Budget', icon: '💰', route: '/Barangay/BudgetLedger', color: 'bg-emerald-500' },
|
||||
{ label: 'Announcements', icon: '📢', route: '/ManageAnnouncements', color: 'bg-yellow-500' },
|
||||
{ label: 'Settings', icon: '⚙️', route: '/SystemSettings', color: 'bg-gray-500' },
|
||||
];
|
||||
|
||||
const serviceCards = [
|
||||
{ label: 'Request Document', icon: '📄', route: '/Barangay/RequestDocument', color: 'bg-blue-500' },
|
||||
{ label: 'View Announcements', icon: '📢', route: '/ManageAnnouncements', color: 'bg-yellow-500' },
|
||||
];
|
||||
|
||||
onMounted(loadStats);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<div
|
||||
class="home-bg-logo"
|
||||
:style="uiStore.appLogo ? { backgroundImage: `url('${uiStore.appLogo}')` } : null"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<div class="home-page-content">
|
||||
<div v-if="!isPublic" class="tf-container mt-3 mb-3">
|
||||
<h2 class="fw_6 text-center">{{ uiStore.appName }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Bible Verse of the Day -->
|
||||
<div v-if="!isPublic && uiStore.bibleVerseText" class="tf-container mt-2 mb-1">
|
||||
<div class="bible-verse-card px-4 py-3 rounded-4 text-center">
|
||||
<i class="fas fa-bible text-primary opacity-50 mb-2 d-block"></i>
|
||||
<p class="fst-italic mb-1 bible-verse-text">"{{ uiStore.bibleVerseText }}"</p>
|
||||
<small v-if="uiStore.bibleVerseReference" class="text-muted fw-semibold">— {{ uiStore.bibleVerseReference }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userStore.loading" class="d-flex justify-content-center align-items-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<!-- Case 1: Logged in as Ultimate -->
|
||||
<template v-if="isUltimate">
|
||||
<HomeUltimate />
|
||||
</template>
|
||||
|
||||
<!-- Case 2: Public / Guest User -->
|
||||
<template v-else-if="isPublic">
|
||||
<HomePublic />
|
||||
</template>
|
||||
|
||||
<!-- Case 3: Super Operator -->
|
||||
<template v-else-if="isSuperOperator">
|
||||
<HomeSuperOperator />
|
||||
</template>
|
||||
|
||||
<!-- Case 4: Operator -->
|
||||
<template v-else-if="isOperator">
|
||||
<HomeOperator />
|
||||
</template>
|
||||
|
||||
<!-- Case 5: Coordinator -->
|
||||
<template v-else-if="isCoordinator">
|
||||
<HomeCooperative />
|
||||
</template>
|
||||
|
||||
<!-- Case 6: Store Owner -->
|
||||
<template v-else-if="isStoreOwner">
|
||||
<HomeStoreOwner title="Store Owner" />
|
||||
</template>
|
||||
|
||||
<!-- Case 7: Store Manager -->
|
||||
<template v-else-if="isStoreManager">
|
||||
<HomeStoreManager />
|
||||
</template>
|
||||
|
||||
<!-- Case 8: Supplier -->
|
||||
<template v-else-if="isSupplier">
|
||||
<HomeShared title="Supplier" />
|
||||
</template>
|
||||
|
||||
<!-- Case 9: Supplier Overseer -->
|
||||
<template v-else-if="isSupplierOverseer">
|
||||
<HomeShared title="Supplier Overseer" />
|
||||
</template>
|
||||
|
||||
<!-- Case 10: Rider -->
|
||||
<template v-else-if="isRider">
|
||||
<HomeShared title="Rider" />
|
||||
</template>
|
||||
|
||||
<!-- Case 11: POS Terminal -->
|
||||
<template v-else-if="isPOSTerminal">
|
||||
<HomeShared title="POS Terminal" />
|
||||
</template>
|
||||
|
||||
<!-- Coop Officer -->
|
||||
<template v-else-if="isCoopOfficer">
|
||||
<HomeCoopOfficer />
|
||||
</template>
|
||||
|
||||
<!-- Coop Member -->
|
||||
<template v-else-if="isCoopMember">
|
||||
<HomeCoopMember />
|
||||
</template>
|
||||
|
||||
<!-- Case 11: Regular User -->
|
||||
<template v-else-if="isUser">
|
||||
<HomeShared title="User" />
|
||||
</template>
|
||||
|
||||
<!-- Fallback Dashboard -->
|
||||
<template v-else>
|
||||
<div class="tf-container mt-5 text-center">
|
||||
<h4>Welcome to {{ uiStore.appName }}</h4>
|
||||
<p>Your account type: <strong>{{ role }}</strong></p>
|
||||
<p>Please wait while we set up your personalized dashboard.</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/9908be28dd8a.bin" class="spinning" style="width: 50px;">
|
||||
<div class="p-4 max-w-5xl mx-auto">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">Barangay Management System</h1>
|
||||
<p class="text-gray-500 text-sm mt-1">Welcome. Here's today's overview.</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats (admin/staff) -->
|
||||
<div v-if="isBarangayStaff" class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div class="bg-white rounded-xl shadow p-4 text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ stats.total_residents ?? 0 }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Residents</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow p-4 text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ stats.total_households ?? 0 }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Households</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow p-4 text-center">
|
||||
<div class="text-3xl font-bold text-orange-600">{{ stats.open_blotters ?? 0 }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Open Blotters</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow p-4 text-center">
|
||||
<div class="text-3xl font-bold text-purple-600">{{ stats.pending_documents ?? 0 }}</div>
|
||||
<div class="text-xs text-gray-500 mt-1">Pending Docs</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Access (admin/staff) -->
|
||||
<div v-if="isBarangayStaff" class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-3">Quick Access</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<button
|
||||
v-for="card in adminCards"
|
||||
:key="card.label"
|
||||
@click="navigate(card.route)"
|
||||
:class="`${card.color} text-white rounded-xl p-4 flex flex-col items-center gap-2 hover:opacity-90 transition`"
|
||||
>
|
||||
<span class="text-3xl">{{ card.icon }}</span>
|
||||
<span class="text-sm font-medium">{{ card.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services (resident) -->
|
||||
<div v-if="isResident" class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-700 mb-3">Services Available to You</h2>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="card in serviceCards"
|
||||
:key="card.label"
|
||||
@click="navigate(card.route)"
|
||||
:class="`${card.color} text-white rounded-xl p-5 flex flex-col items-center gap-2 hover:opacity-90 transition`"
|
||||
>
|
||||
<span class="text-4xl">{{ card.icon }}</span>
|
||||
<span class="text-sm font-medium">{{ card.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-page {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.home-bg-logo {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center 35%;
|
||||
background-size: min(60vw, 520px) auto;
|
||||
opacity: 0.12;
|
||||
filter: saturate(0.9);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .home-bg-logo {
|
||||
opacity: 0.15;
|
||||
filter: invert(1) saturate(0.8);
|
||||
}
|
||||
|
||||
.home-page-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.bible-verse-card {
|
||||
background: rgba(var(--bg-card-rgb, 255, 255, 255), 0.7);
|
||||
border: 1px solid rgba(var(--accent-color-rgb, 13, 110, 253), 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.bible-verse-text {
|
||||
color: var(--text-primary, #212529);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('List Products Market');
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import SearchBar from '../Components/Core/Search/SearchBar.vue';
|
||||
import ProductCard from '../Components/Market/ProductCard.vue';
|
||||
import ProductListSkeleton from '../Components/Core/Skeleton/ProductListSkeleton.vue';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: () => ({}) }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { isUltimate, isSuperOperator, isOperator } = useAuth();
|
||||
|
||||
import { useProductStore } from '../stores/product';
|
||||
const productStore = useProductStore();
|
||||
|
||||
const products = computed(() => productStore.products);
|
||||
const loading = computed(() => productStore.loading);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const fetchProducts = async () => {
|
||||
// Only fetch if empty, productStore already handles the logic
|
||||
await productStore.fetchProducts();
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
});
|
||||
|
||||
const viewProduct = (product) => {
|
||||
if (props.data?.store_hash) {
|
||||
navigate({
|
||||
page: 'AssignProductToStore',
|
||||
props: {
|
||||
payload: { product_hashkey: product.hashkey, store_hashkey: props.data.store_hash }
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate({
|
||||
page: 'BuyViewProductMarket',
|
||||
props: { target: product.hashkey }
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchProducts();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-products-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<h3 class="fw_6 mb-0">Marketplace</h3>
|
||||
<button v-if="isUltimate || isSuperOperator || isOperator" @click="navigate({ page: 'CreateProductUltimate' })"
|
||||
class="btn btn-sm btn-primary rounded-pill px-3 py-1">
|
||||
<i class="fas fa-plus me-1"></i> New Product
|
||||
</button>
|
||||
</div>
|
||||
<div class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
||||
{{ filteredProducts.length }} Products
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data?.store_name" class="alert alert-info py-2 rounded-15 mb-4 animate-fade-in">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Please select a product to add to <strong>{{ data.store_name }}</strong>
|
||||
<button @click="navigate({ page: 'ViewStoreMarket', props: { target: data.store_hash } })" class="btn btn-link btn-sm float-end p-0">Cancel</button>
|
||||
</div>
|
||||
|
||||
<SearchBar v-model="searchQuery" placeholder="Search products..." class="mb-4" />
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<ProductListSkeleton :count="12" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredProducts.length === 0" class="text-center py-5 no-results">
|
||||
<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</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="row g-3 product-grid">
|
||||
<div v-for="product in filteredProducts" :key="product.hashkey" class="col-6 col-md-4 col-lg-3">
|
||||
<ProductCard :name="product.name" :price="product.price" :unit="product.unit"
|
||||
:description="product.description" :image="product.photo ? product.photo[0] : ''"
|
||||
@click="viewProduct(product)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge.bg-soft-primary {
|
||||
background-color: rgba(66, 185, 131, 0.1);
|
||||
}
|
||||
|
||||
.product-grid {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
background: #f8f9fa;
|
||||
border-radius: 20px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .no-results {
|
||||
background: #1a1c20;
|
||||
border-color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
usePageTitle('Property Listings')
|
||||
const { navigate } = useNavigate()
|
||||
|
||||
const properties = ref([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchProperties()
|
||||
})
|
||||
|
||||
const fetchProperties = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/properties/list')
|
||||
properties.value = response.data.properties || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching properties:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-PH', {
|
||||
style: 'currency',
|
||||
currency: 'PHP'
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-properties-page pb-5">
|
||||
<br><br>
|
||||
<div class="tf-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<BackButton to="Home" />
|
||||
<h4 class="fw_6 mb-0">Property Management</h4>
|
||||
<button class="btn btn-primary btn-sm rounded-pill px-3 shadow-sm">
|
||||
<i class="fas fa-plus me-1"></i> Add Property
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<i class="fas fa-spinner fa-spin fa-3x text-muted"></i>
|
||||
<p class="mt-3">Loading properties...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="properties.length === 0" class="card shadow-sm border-0 rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-home fa-4x text-muted mb-3"></i>
|
||||
<h5>No Properties Found</h5>
|
||||
<p class="text-muted">Register your real estate listings here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="row">
|
||||
<div v-for="property in properties" :key="property.id" class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="property-card card h-100 shadow-sm border-0 rounded-3">
|
||||
<div class="card-body">
|
||||
<h5 class="fw_6">{{ property.name }}</h5>
|
||||
<p class="text-muted small mb-2"><i class="fas fa-map-marker-alt me-1"></i> {{ property.location }}</p>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<span class="fw_7 text-primary">{{ formatCurrency(property.price) }}</span>
|
||||
<span class="badge bg-success-subtle text-success">{{ property.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
|
||||
<button class="btn btn-sm btn-outline-primary w-100 rounded-pill">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tf-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.property-card {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.property-card:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
usePageTitle('Property Referrals')
|
||||
const { navigate } = useNavigate()
|
||||
|
||||
const referrals = ref([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchReferrals()
|
||||
})
|
||||
|
||||
const fetchReferrals = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/properties/referrals')
|
||||
referrals.value = response.data.referrals || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching referrals:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-referrals-page pb-5">
|
||||
<br><br>
|
||||
<div class="tf-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<BackButton to="Home" />
|
||||
<h4 class="fw_6 mb-0">Referral Tracking</h4>
|
||||
<button class="btn btn_primary btn-sm rounded-pill px-3 shadow-sm">
|
||||
<i class="fas fa-plus me-1"></i> New Referral
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<i class="fas fa-spinner fa-spin fa-3x text-muted"></i>
|
||||
<p class="mt-3">Loading referrals...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="referrals.length === 0" class="card shadow-sm border-0 rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-users-cog fa-4x text-muted mb-3"></i>
|
||||
<h5>No Referrals Yet</h5>
|
||||
<p class="text-muted">Track property leads and referrals here.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card shadow-sm border-0 rounded-3 overflow-hidden">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Referral ID</th>
|
||||
<th>Property</th>
|
||||
<th>Referrer</th>
|
||||
<th>Lead Name</th>
|
||||
<th>Status</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="referral in referrals" :key="referral.id">
|
||||
<td><span class="fw-bold">#{{ referral.hashkey?.substring(0, 8) }}</span></td>
|
||||
<td>{{ referral.property?.name }}</td>
|
||||
<td>{{ referral.referrer?.name }}</td>
|
||||
<td>{{ referral.referred_name || referral.referred?.name }}</td>
|
||||
<td>
|
||||
<span class="badge bg-warning text-dark">{{ referral.status }}</span>
|
||||
</td>
|
||||
<td>{{ new Date(referral.created_at).toLocaleDateString() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tf-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
usePageTitle('Ultimate Reports')
|
||||
const { navigate } = useNavigate()
|
||||
|
||||
const transactions = ref([])
|
||||
const isLoading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchReports()
|
||||
})
|
||||
|
||||
const fetchReports = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.post('/admin/accounting/reports', {})
|
||||
transactions.value = response.data.transactions || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching reports:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-PH', {
|
||||
style: 'currency',
|
||||
currency: 'PHP'
|
||||
}).format(amount)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-reports-page pb-5">
|
||||
<br><br>
|
||||
<div class="tf-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<BackButton to="Home" />
|
||||
<h4 class="fw_6 mb-0">Accounting Reports</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm rounded-pill px-3">
|
||||
<i class="fas fa-download me-1"></i> Export PDF
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm rounded-pill px-3 shadow-sm">
|
||||
<i class="fas fa-filter me-1"></i> Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<i class="fas fa-chart-line fa-spin fa-3x text-muted"></i>
|
||||
<p class="mt-3">Generating reports...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="transactions.length === 0" class="card shadow-sm border-0 rounded-3">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-file-invoice-dollar fa-4x text-muted mb-3"></i>
|
||||
<h5>No Accounting Data</h5>
|
||||
<p class="text-muted">Financial reports will appear here once transactions are recorded.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card shadow-sm border-0 rounded-3 overflow-hidden">
|
||||
<div class="card-header bg-white border-bottom py-3">
|
||||
<h5 class="mb-0 fw_6">Recent Transactions</h5>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
<th>Recorded By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tx in transactions" :key="tx.id">
|
||||
<td>{{ new Date(tx.transaction_date).toLocaleDateString() }}</td>
|
||||
<td>{{ tx.item }}</td>
|
||||
<td><span class="badge bg-info-subtle text-info">{{ tx.account_type?.name }}</span></td>
|
||||
<td class="fw-bold" :class="tx.amount > 0 ? 'text-success' : 'text-danger'">
|
||||
{{ formatCurrency(tx.amount) }}
|
||||
</td>
|
||||
<td>{{ tx.creator?.name }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tf-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('List Stores');
|
||||
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useFileBlobCache } from '../composables/useFileBlobCache';
|
||||
import SearchBar from '../Components/Core/Search/SearchBar.vue';
|
||||
import StoreCard from '../Components/Market/StoreCard.vue';
|
||||
import StoreListSkeleton from '../Components/Core/Skeleton/StoreListSkeleton.vue';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import usePageData from '../composables/usePageData';
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { preCacheFiles, blobCache } = useFileBlobCache();
|
||||
const { data: pageData, loading: pageLoading, fetchPageData } = usePageData();
|
||||
|
||||
const stores = ref([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const goToAddStore = () => {
|
||||
navigate({ page: 'CreateStore' });
|
||||
};
|
||||
|
||||
const fetchStores = async () => {
|
||||
loading.value = true; // Still show local loading if NO cache exists
|
||||
const result = await fetchPageData('/ListStores/List/data', {}, 'POST');
|
||||
if (result && result.data) {
|
||||
stores.value = result.data;
|
||||
}
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const filteredStores = computed(() => {
|
||||
if (!searchQuery.value) return stores.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return stores.value.filter(s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q) ||
|
||||
s.subcategory?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const viewStore = (store) => {
|
||||
navigate({
|
||||
page: 'ViewStoreMarket',
|
||||
props: { target: store.hashkey }
|
||||
});
|
||||
};
|
||||
|
||||
// Pre-cache store photos when stores are loaded
|
||||
const preCacheStorePhotos = async () => {
|
||||
const photoHashes = stores.value
|
||||
.map(store => store.photourl)
|
||||
.filter(hash => hash && hash.length);
|
||||
|
||||
await preCacheFiles(photoHashes);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchStores();
|
||||
// Pre-cache photos after stores are loaded
|
||||
await preCacheStorePhotos();
|
||||
});
|
||||
|
||||
// Watch for stores changes and pre-cache photos
|
||||
watch(stores, (newStores) => {
|
||||
if (newStores && newStores.length > 0) {
|
||||
const photoHashes = newStores
|
||||
.map(store => store.photourl)
|
||||
.filter(hash => hash && hash.length);
|
||||
preCacheFiles(photoHashes);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-stores-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="fw_6 mb-0">Browse Stores</h3>
|
||||
<div class="badge bg-soft-info px-3 py-2 rounded-pill text-info">
|
||||
{{ filteredStores.length }} Stores
|
||||
</div>
|
||||
<button @click="goToAddStore" class="btn btn-primary rounded-pill px-3">
|
||||
Create Store
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SearchBar v-model="searchQuery" placeholder="Search stores..." class="mb-4" />
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<StoreListSkeleton :count="6" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredStores.length === 0" class="text-center py-5 no-results">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin" style="width: 120px; opacity: 0.3;" class="mb-3">
|
||||
<h5>No stores found</h5>
|
||||
<p class="text-muted">Try adjusting your search criteria</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="row g-3 store-grid">
|
||||
<div v-for="store in filteredStores" :key="store.hashkey" class="col-12 col-sm-6 col-md-4">
|
||||
<StoreCard :name="store.name" :category="store.category" :subcategory="store.subcategory"
|
||||
:image="blobCache[store.photourl] || 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin'" @click="viewStore(store)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge.bg-soft-info {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.store-grid {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
background: #f8f9fa;
|
||||
border-radius: 20px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .no-results {
|
||||
background: #1a1c20;
|
||||
border-color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
@@ -1,655 +0,0 @@
|
||||
<template>
|
||||
<div class="manage-accounts 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 gap-4">
|
||||
<div class="bg-white rounded-circle p-3 shadow">
|
||||
<i class="fas fa-sitemap fa-2x text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="fw-bold text-white mb-0">Manage Accounts</h2>
|
||||
<p class="text-white-50 small text-uppercase ls-wide mt-1">Chart of Accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
|
||||
<!-- Store selector (store owner / store manager only) -->
|
||||
<div v-if="stores.length > 0" class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<label class="fw-semibold small text-muted mb-0">Store:</label>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="s in stores"
|
||||
:key="s.hashkey"
|
||||
class="btn btn-sm"
|
||||
:class="selectedStoreId === s.id ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@click="selectStore(s)"
|
||||
>
|
||||
<i class="fas fa-store me-1"></i>{{ s.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accounting Theme Panel (Big 3 only) -->
|
||||
<div v-if="isBig3" class="card border-0 shadow-sm rounded-4 bg-white mb-3">
|
||||
<div
|
||||
class="card-header bg-white border-0 d-flex align-items-center justify-content-between py-3 px-3"
|
||||
style="cursor:pointer"
|
||||
@click="themeOpen = !themeOpen"
|
||||
>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="fas fa-palette text-primary"></i>
|
||||
<span class="fw-bold small">Accounting Theme</span>
|
||||
<span v-if="themeInfo" class="badge bg-primary-subtle text-primary small ms-1">{{ themeInfo.definition.label }}</span>
|
||||
</div>
|
||||
<i class="fas text-muted" :class="themeOpen ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="themeOpen" class="card-body pt-0 px-3 pb-3">
|
||||
<div v-if="loadingThemes" class="text-center py-3 text-muted small">
|
||||
<i class="fas fa-circle-notch fa-spin me-1"></i> Loading themes…
|
||||
</div>
|
||||
<div v-else-if="themeInfo">
|
||||
<div class="row g-2 mb-3">
|
||||
<div v-for="opt in themeInfo.options" :key="opt.key" class="col-6 col-md-4 col-lg-3">
|
||||
<div
|
||||
class="p-2 rounded-3 border h-100"
|
||||
:class="opt.key === themeInfo.current ? 'border-primary border-2 bg-primary-subtle' : (opt.key === selectThemeKey ? 'border-primary' : 'border-light-subtle bg-light')"
|
||||
style="cursor:pointer; font-size:.82rem"
|
||||
@click="selectThemeKey = opt.key"
|
||||
>
|
||||
<div class="d-flex align-items-center gap-1 mb-1">
|
||||
<i class="fas fa-palette text-primary" style="font-size:.7rem"></i>
|
||||
<span class="fw-bold">{{ opt.label }}</span>
|
||||
<span v-if="opt.key === themeInfo.current" class="badge bg-primary ms-auto" style="font-size:.6rem">Active</span>
|
||||
</div>
|
||||
<div class="text-muted" style="font-size:.72rem; line-height:1.3">{{ opt.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-items-center gap-3 p-2 bg-light rounded-3 small">
|
||||
<span class="text-muted">Theme accounts: <strong>{{ themeInfo.counts.theme_tagged }}</strong></span>
|
||||
<span class="text-muted">User-added: <strong>{{ themeInfo.counts.user_added }}</strong></span>
|
||||
<span v-if="themeDrift && themeDrift.totals.missing > 0" class="text-warning">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>Missing: <strong>{{ themeDrift.totals.missing }}</strong>
|
||||
</span>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
:disabled="!selectThemeKey || selectThemeKey === themeInfo.current || switchingTheme"
|
||||
@click="switchTheme"
|
||||
>
|
||||
<i class="fas fa-shuffle me-1"></i>{{ switchingTheme ? 'Switching…' : 'Switch' }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="applyingTheme"
|
||||
@click="reapplyTheme"
|
||||
>
|
||||
<i class="fas fa-sync me-1" :class="{ 'fa-spin': applyingTheme }"></i>
|
||||
{{ applyingTheme ? 'Applying…' : 'Apply theme' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-muted mt-2 mb-0" style="font-size:.75rem">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Switching only changes which theme is tracked — it does not seed accounts. Apply is additive: never deletes or renames.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||
<h4 class="fw-bold mb-0"><i class="fas fa-folder-tree text-primary me-2"></i> Hierarchy</h4>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="includeArchived = !includeArchived">
|
||||
<i class="fas" :class="includeArchived ? 'fa-eye-slash' : 'fa-eye'"></i>
|
||||
{{ includeArchived ? 'Hide archived' : 'Show archived' }}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" @click="openCreate(null)">
|
||||
<i class="fas fa-plus me-1"></i> Add root account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-lg rounded-4 bg-white">
|
||||
<div class="card-body p-3">
|
||||
<div v-if="loading" class="text-center text-muted py-4">
|
||||
<i class="fas fa-circle-notch fa-spin me-2"></i> Loading…
|
||||
</div>
|
||||
<div v-else-if="visibleTree.length === 0" class="text-center text-muted py-5">
|
||||
<i class="fas fa-inbox fa-2x mb-2 d-block"></i>
|
||||
No accounts yet. Click <strong>Add root account</strong> to get started.
|
||||
</div>
|
||||
<ul v-else class="list-unstyled mb-0">
|
||||
<AccountNode
|
||||
v-for="node in visibleTree"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:include-archived="includeArchived"
|
||||
@add-child="openCreate"
|
||||
@edit="openEdit"
|
||||
@move="openMove"
|
||||
@archive="archive"
|
||||
@restore="restore"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Create modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="modal.open" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-card card border-0 shadow-lg rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">
|
||||
{{ modal.mode === 'create' ? 'Add account' : 'Edit account' }}
|
||||
</h5>
|
||||
<div v-if="modal.parentName" class="small text-muted mb-3">
|
||||
Under: <strong>{{ modal.parentName }}</strong>
|
||||
</div>
|
||||
|
||||
<!-- Inline error — visible even when this modal is open -->
|
||||
<div v-if="modal.error" class="alert alert-danger py-2 small mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{{ modal.error }}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Name</label>
|
||||
<input v-model="form.name" type="text" class="form-control" placeholder="Account name" />
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Type</label>
|
||||
<select v-model="form.type" class="form-select">
|
||||
<option value="REVENUE">REVENUE</option>
|
||||
<option value="EXPENSE">EXPENSE</option>
|
||||
<option value="ASSET">ASSET</option>
|
||||
<option value="LIABILITY">LIABILITY</option>
|
||||
<option value="EQUITY">EQUITY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Default flow</label>
|
||||
<select v-model="form.default_flow" class="form-select">
|
||||
<option value="INCOME">INCOME</option>
|
||||
<option value="EXPENSE">EXPENSE</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="form-label small fw-bold">Description <span class="text-muted fw-normal">(optional)</span></label>
|
||||
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button class="btn btn-outline-secondary" @click="closeModal" :disabled="saving">Cancel</button>
|
||||
<button class="btn btn-primary" @click="save" :disabled="saving || !form.name.trim()">
|
||||
<i class="fas fa-save me-1"></i>
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Move modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="moveModal.open" class="modal-overlay" @click.self="closeMoveModal">
|
||||
<div class="modal-card card border-0 shadow-lg rounded-4">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Move account</h5>
|
||||
<div class="small text-muted mb-3">
|
||||
Moving: <strong>{{ moveModal.node?.name }}</strong>
|
||||
</div>
|
||||
|
||||
<div v-if="moveModal.error" class="alert alert-danger py-2 small mb-3">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>{{ moveModal.error }}
|
||||
</div>
|
||||
|
||||
<label class="form-label small fw-bold">New parent</label>
|
||||
<select v-model="moveModal.targetParentId" class="form-select">
|
||||
<option :value="null">— Root (no parent) —</option>
|
||||
<option v-for="cand in moveCandidates" :key="cand.id" :value="cand.id">
|
||||
{{ cand.indent }}{{ cand.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="small text-muted mt-2">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
The account being moved and its descendants are excluded to prevent cycles.
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-4">
|
||||
<button class="btn btn-outline-secondary" @click="closeMoveModal" :disabled="moving">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMove" :disabled="moving">
|
||||
<i class="fas fa-arrow-right me-1"></i>
|
||||
{{ moving ? 'Moving…' : 'Move' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Notice modal (for archive/restore/global errors outside an open modal) -->
|
||||
<BaseModal
|
||||
v-model="notice.open"
|
||||
:modalTitle="notice.title"
|
||||
:footerClose="true"
|
||||
>
|
||||
<div class="d-flex align-items-start gap-3 py-2">
|
||||
<div class="icon-circle flex-shrink-0" :class="noticeIconBg">
|
||||
<i class="fas" :class="noticeIcon"></i>
|
||||
</div>
|
||||
<div class="pt-1" style="white-space: pre-wrap;">{{ notice.message }}</div>
|
||||
</div>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Confirm modal -->
|
||||
<ConfirmModal
|
||||
v-model="confirmState.open"
|
||||
:title="confirmState.title"
|
||||
:message="confirmState.message"
|
||||
:confirmText="confirmState.confirmText"
|
||||
:cancelText="confirmState.cancelText"
|
||||
:variant="confirmState.variant"
|
||||
@confirm="onConfirmAccept"
|
||||
@cancel="onConfirmReject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import BaseModal from '@/Components/Core/BaseModal.vue';
|
||||
import ConfirmModal from '@/Components/Core/ConfirmModal.vue';
|
||||
import { useAuth } from '@/composables/Core/useAuth';
|
||||
|
||||
const { isUltimate, isSuperOperator, isOperator } = useAuth();
|
||||
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
|
||||
|
||||
// Recursive node component
|
||||
const AccountNode = {
|
||||
name: 'AccountNode',
|
||||
props: { node: Object, includeArchived: Boolean },
|
||||
emits: ['add-child', 'edit', 'move', 'archive', 'restore'],
|
||||
setup(props, { emit }) {
|
||||
const expanded = ref(true);
|
||||
const children = computed(() => {
|
||||
const list = props.node.children || [];
|
||||
return props.includeArchived ? list : list.filter(c => c.is_active);
|
||||
});
|
||||
const hasChildren = computed(() => children.value.length > 0);
|
||||
return { expanded, children, hasChildren };
|
||||
},
|
||||
template: `
|
||||
<li class="account-node mb-1">
|
||||
<div class="d-flex align-items-center gap-2 py-2 px-2 rounded hover-bg" :class="{ 'opacity-50': !node.is_active }">
|
||||
<button class="btn btn-sm btn-link p-0 toggle-btn" v-if="hasChildren" @click="expanded = !expanded">
|
||||
<i class="fas" :class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"></i>
|
||||
</button>
|
||||
<span v-else class="toggle-spacer"></span>
|
||||
|
||||
<span class="badge" :class="node.default_flow === 'INCOME' ? 'bg-success-subtle text-success' : 'bg-danger-subtle text-danger'">
|
||||
{{ node.default_flow || node.type }}
|
||||
</span>
|
||||
<span class="fw-semibold">{{ node.name }}</span>
|
||||
<span v-if="node.theme_key" class="badge bg-light text-muted small" :title="'theme: ' + node.theme_key">
|
||||
<i class="fas fa-palette"></i>
|
||||
</span>
|
||||
<span v-if="!node.is_active" class="badge bg-secondary small">archived</span>
|
||||
|
||||
<div class="ms-auto d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-primary" @click="$emit('add-child', node)" title="Add child">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="$emit('edit', node)" title="Edit">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="$emit('move', node)" title="Move">
|
||||
<i class="fas fa-arrows-up-down-left-right"></i>
|
||||
</button>
|
||||
<button v-if="node.is_active" class="btn btn-sm btn-outline-danger" @click="$emit('archive', node)" title="Archive">
|
||||
<i class="fas fa-archive"></i>
|
||||
</button>
|
||||
<button v-else class="btn btn-sm btn-outline-success" @click="$emit('restore', node)" title="Restore">
|
||||
<i class="fas fa-undo"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul v-if="hasChildren && expanded" class="list-unstyled ms-4 ps-3 border-start">
|
||||
<account-node
|
||||
v-for="child in children"
|
||||
:key="child.id"
|
||||
:node="child"
|
||||
:include-archived="includeArchived"
|
||||
@add-child="(n) => $emit('add-child', n)"
|
||||
@edit="(n) => $emit('edit', n)"
|
||||
@move="(n) => $emit('move', n)"
|
||||
@archive="(n) => $emit('archive', n)"
|
||||
@restore="(n) => $emit('restore', n)"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
`,
|
||||
};
|
||||
|
||||
const tree = ref([]);
|
||||
const loading = ref(false);
|
||||
const includeArchived = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
// Store selector (store-level users only)
|
||||
const stores = ref([]);
|
||||
const selectedStoreId = ref(null);
|
||||
|
||||
// Theme management (Big 3 only)
|
||||
const themeOpen = ref(false);
|
||||
const themeInfo = ref(null);
|
||||
const themeDrift = ref(null);
|
||||
const loadingThemes = ref(false);
|
||||
const selectThemeKey = ref(null);
|
||||
const switchingTheme = ref(false);
|
||||
const applyingTheme = ref(false);
|
||||
|
||||
const modal = ref({ open: false, mode: 'create', parentName: null, error: null });
|
||||
const form = ref(emptyForm());
|
||||
const moveModal = ref({ open: false, node: null, targetParentId: null, error: null });
|
||||
const moving = ref(false);
|
||||
|
||||
// Notice (for errors that happen outside an open modal: archive, restore)
|
||||
const notice = ref({ open: false, variant: 'info', title: 'Notice', message: '' });
|
||||
const noticeIcon = computed(() => {
|
||||
if (notice.value.variant === 'danger') return 'fa-exclamation-triangle text-danger';
|
||||
if (notice.value.variant === 'warning') return 'fa-exclamation-circle text-warning';
|
||||
if (notice.value.variant === 'success') return 'fa-check-circle text-success';
|
||||
return 'fa-info-circle text-primary';
|
||||
});
|
||||
const noticeIconBg = computed(() => {
|
||||
if (notice.value.variant === 'danger') return 'bg-soft-danger';
|
||||
if (notice.value.variant === 'warning') return 'bg-soft-warning';
|
||||
if (notice.value.variant === 'success') return 'bg-soft-success';
|
||||
return 'bg-soft-primary';
|
||||
});
|
||||
function showNotice(message, { variant = 'info', title } = {}) {
|
||||
notice.value = {
|
||||
open: true, variant,
|
||||
title: title ?? (variant === 'danger' ? 'Error' : variant === 'success' ? 'Success' : 'Notice'),
|
||||
message: String(message ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
// Confirm modal
|
||||
const confirmState = ref({ open: false, title: 'Confirm Action', message: '', confirmText: 'Confirm', cancelText: 'Cancel', variant: 'info' });
|
||||
let confirmResolver = null;
|
||||
function askConfirm(message, opts = {}) {
|
||||
confirmState.value = { open: true, title: opts.title ?? 'Confirm Action', message, confirmText: opts.confirmText ?? 'Confirm', cancelText: opts.cancelText ?? 'Cancel', variant: opts.variant ?? 'info' };
|
||||
return new Promise((resolve) => { confirmResolver = resolve; });
|
||||
}
|
||||
function onConfirmAccept() { if (confirmResolver) { confirmResolver(true); confirmResolver = null; } }
|
||||
function onConfirmReject() { if (confirmResolver) { confirmResolver(false); confirmResolver = null; } }
|
||||
|
||||
const moveCandidates = computed(() => {
|
||||
if (!moveModal.value.node) return [];
|
||||
const excludeId = moveModal.value.node.id;
|
||||
const list = [];
|
||||
const walk = (nodes, depth) => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === excludeId) continue;
|
||||
if (!n.is_active) continue;
|
||||
list.push({ id: n.id, name: n.name, indent: ' '.repeat(depth) });
|
||||
if (n.children?.length) walk(n.children, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(tree.value, 0);
|
||||
return list;
|
||||
});
|
||||
|
||||
function emptyForm() {
|
||||
return { id: null, parent_id: null, name: '', type: 'REVENUE', default_flow: 'INCOME', description: '' };
|
||||
}
|
||||
|
||||
const visibleTree = computed(() =>
|
||||
includeArchived.value ? tree.value : tree.value.filter(n => n.is_active)
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isBig3.value) {
|
||||
await fetchStores();
|
||||
} else {
|
||||
loadThemes();
|
||||
}
|
||||
fetchAll();
|
||||
});
|
||||
|
||||
async function loadThemes() {
|
||||
loadingThemes.value = true;
|
||||
try {
|
||||
const [infoRes, driftRes] = await Promise.all([
|
||||
axios.post('/admin/accounting/theme', {}),
|
||||
axios.post('/admin/accounting/theme/drift', {}),
|
||||
]);
|
||||
themeInfo.value = infoRes.data ?? null;
|
||||
themeDrift.value = driftRes.data?.data ?? null;
|
||||
if (themeInfo.value?.current && !selectThemeKey.value) {
|
||||
selectThemeKey.value = themeInfo.value.current;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load themes', e);
|
||||
} finally {
|
||||
loadingThemes.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function switchTheme() {
|
||||
if (!selectThemeKey.value || selectThemeKey.value === themeInfo.value?.current) return;
|
||||
switchingTheme.value = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/theme/set', { key: selectThemeKey.value });
|
||||
if (res.data?.success) {
|
||||
await loadThemes();
|
||||
await fetchAll();
|
||||
await reapplyTheme();
|
||||
} else {
|
||||
showNotice(res.data?.message || 'Could not switch theme.', { variant: 'danger' });
|
||||
}
|
||||
} catch (e) {
|
||||
showNotice(e.response?.data?.message || 'Could not switch theme.', { variant: 'danger' });
|
||||
} finally {
|
||||
switchingTheme.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reapplyTheme() {
|
||||
applyingTheme.value = true;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/theme/apply', {});
|
||||
if (res.data?.success) {
|
||||
showNotice(res.data.message || 'Theme applied.', { variant: 'success', title: 'Theme Applied' });
|
||||
await loadThemes();
|
||||
await fetchAll();
|
||||
} else {
|
||||
showNotice(res.data?.message || 'Apply failed.', { variant: 'danger' });
|
||||
}
|
||||
} catch (e) {
|
||||
showNotice(e.response?.data?.message || 'Apply failed.', { variant: 'danger' });
|
||||
} finally {
|
||||
applyingTheme.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStores() {
|
||||
try {
|
||||
const res = await axios.post('/Admin/Stores/Selectable', {});
|
||||
stores.value = res.data?.data ?? [];
|
||||
if (stores.value.length > 0) {
|
||||
selectedStoreId.value = stores.value[0].id ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stores', e);
|
||||
}
|
||||
}
|
||||
|
||||
function selectStore(s) {
|
||||
selectedStoreId.value = s.id ?? null;
|
||||
fetchAll();
|
||||
}
|
||||
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const treeRes = await axios.post('/admin/accounting/tree', {});
|
||||
tree.value = treeRes.data?.data ?? [];
|
||||
} catch (e) {
|
||||
console.error('Failed to load accounts', e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate(parentNode) {
|
||||
form.value = emptyForm();
|
||||
if (parentNode) {
|
||||
form.value.parent_id = parentNode.id;
|
||||
form.value.type = parentNode.type || 'REVENUE';
|
||||
form.value.default_flow = parentNode.default_flow || 'INCOME';
|
||||
}
|
||||
modal.value = { open: true, mode: 'create', parentName: parentNode?.name ?? null, error: null };
|
||||
}
|
||||
|
||||
function openEdit(node) {
|
||||
form.value = {
|
||||
id: node.id,
|
||||
parent_id: node.parent_id,
|
||||
name: node.name,
|
||||
type: node.type,
|
||||
default_flow: node.default_flow || 'INCOME',
|
||||
description: node.description || '',
|
||||
};
|
||||
modal.value = { open: true, mode: 'edit', parentName: null, error: null };
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (saving.value) return;
|
||||
modal.value.open = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
modal.value.error = null;
|
||||
try {
|
||||
const url = modal.value.mode === 'create'
|
||||
? '/admin/accounting/accounts/create'
|
||||
: '/admin/accounting/accounts/update';
|
||||
const res = await axios.post(url, form.value);
|
||||
if (!res.data?.success) {
|
||||
modal.value.error = res.data?.message || 'Save failed.';
|
||||
return;
|
||||
}
|
||||
modal.value.open = false;
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
// Show error inline so it's visible even while this modal is open
|
||||
modal.value.error = e.response?.data?.message || 'Save failed. Please try again.';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openMove(node) {
|
||||
moveModal.value = { open: true, node, targetParentId: node.parent_id ?? null, error: null };
|
||||
}
|
||||
|
||||
function closeMoveModal() {
|
||||
if (moving.value) return;
|
||||
moveModal.value.open = false;
|
||||
}
|
||||
|
||||
async function confirmMove() {
|
||||
if (!moveModal.value.node) return;
|
||||
moving.value = true;
|
||||
moveModal.value.error = null;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/accounts/move', {
|
||||
id: moveModal.value.node.id,
|
||||
parent_id: moveModal.value.targetParentId,
|
||||
});
|
||||
if (!res.data?.success) {
|
||||
moveModal.value.error = res.data?.message || 'Move failed.';
|
||||
return;
|
||||
}
|
||||
moveModal.value.open = false;
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
moveModal.value.error = e.response?.data?.message || 'Move failed.';
|
||||
} finally {
|
||||
moving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(node) {
|
||||
const ok = await askConfirm(
|
||||
`Archive "${node.name}"? It will be hidden from new transactions.`,
|
||||
{ title: 'Archive account', confirmText: 'Archive', variant: 'danger' }
|
||||
);
|
||||
if (!ok) return;
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/accounts/archive', { id: node.id });
|
||||
if (!res.data?.success) { showNotice(res.data?.message || 'Archive failed.', { variant: 'danger' }); return; }
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
showNotice(e.response?.data?.message || 'Archive failed.', { variant: 'danger' });
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(node) {
|
||||
try {
|
||||
const res = await axios.post('/admin/accounting/accounts/restore', { id: node.id });
|
||||
if (!res.data?.success) { showNotice(res.data?.message || 'Restore failed.', { variant: 'danger' }); return; }
|
||||
await fetchAll();
|
||||
} catch (e) {
|
||||
showNotice(e.response?.data?.message || 'Restore failed.', { variant: 'danger' });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-primary-gradient { background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%); }
|
||||
.toggle-btn { width: 1.5rem; }
|
||||
.toggle-spacer { display: inline-block; width: 1.5rem; }
|
||||
.hover-bg:hover { background-color: rgba(13, 110, 253, 0.06); }
|
||||
.account-node ul { margin-top: .25rem; }
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
z-index: 1080;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.modal-card { width: 100%; max-width: 520px; background: #fff; }
|
||||
.ls-wide { letter-spacing: .08em; }
|
||||
|
||||
.icon-circle {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.bg-soft-danger { background-color: rgba(231, 76, 60, 0.12); }
|
||||
.bg-soft-warning { background-color: rgba(243, 156, 18, 0.12); }
|
||||
.bg-soft-success { background-color: rgba(46, 204, 113, 0.12); }
|
||||
.bg-soft-primary { background-color: rgba(52, 152, 219, 0.12); }
|
||||
</style>
|
||||
@@ -1,358 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Manage Global Transactions');
|
||||
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import FileImage from '../Components/Core/FileImage.vue'
|
||||
import { useGlobalTransactions } from '../composables/useGlobalTransactions'
|
||||
import { useUserSettings } from '../composables/useUserSettings'
|
||||
import SkeletonTable from '../Components/Core/Skeleton/SkeletonTable.vue'
|
||||
import TableDensityToggle from '../Components/Core/TableDensityToggle.vue'
|
||||
import SearchBar from '../Components/Core/Search/SearchBar.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const { settings, updateSetting } = useUserSettings();
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
|
||||
// Route parameters
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const targetHash = urlParams.get('target') || urlParams.get('product_id')
|
||||
|
||||
const { transactions, isLoading, fetchTransactions } = useGlobalTransactions(
|
||||
targetHash ? { product_id: targetHash } : {}
|
||||
)
|
||||
|
||||
// Data state for product (only if in product context)
|
||||
const productData = ref({})
|
||||
|
||||
// Initialize component
|
||||
onMounted(async () => {
|
||||
document.title = targetHash ? 'Product Transactions' : 'Global Transactions'
|
||||
|
||||
await fetchTransactions()
|
||||
|
||||
if (targetHash) {
|
||||
loadProductData()
|
||||
}
|
||||
})
|
||||
|
||||
// Load product data if in product context
|
||||
const loadProductData = async () => {
|
||||
try {
|
||||
// Get product details
|
||||
const productResponse = await axios.post('/View/Product/Details/data', {
|
||||
target: 'product_id',
|
||||
data: { product_id: targetHash }
|
||||
})
|
||||
|
||||
if (productResponse.data && productResponse.data.success && productResponse.data.data) {
|
||||
productData.value = productResponse.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading product data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('en-PH', {
|
||||
style: 'currency',
|
||||
currency: 'PHP'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Navigation helpers
|
||||
const goToManageProduct = () => {
|
||||
if (targetHash) {
|
||||
navigate({ page: 'ManageProductAdmin', query: { target: targetHash } })
|
||||
} else {
|
||||
navigate({ page: 'ManageProductsAdmin' })
|
||||
}
|
||||
}
|
||||
|
||||
const goToHome = () => {
|
||||
navigate({ page: 'Home' })
|
||||
}
|
||||
|
||||
const goToStoreTransactions = () => {
|
||||
if (productData.value.store_id) {
|
||||
navigate({
|
||||
page: 'ManageStoreTransactions',
|
||||
query: { store_hash: productData.value.store_id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Computed for page header
|
||||
const pageHeader = computed(() => {
|
||||
if (targetHash && productData.value.name) {
|
||||
return `Transactions for ${productData.value.name}`
|
||||
}
|
||||
return 'Global Transaction History'
|
||||
})
|
||||
|
||||
const tableDensity = computed({
|
||||
get: () => settings.value.table_density || 'comfortable',
|
||||
set: (val) => updateSetting('table_density', val)
|
||||
});
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredTransactions = computed(() => {
|
||||
if (!searchQuery.value) return transactions.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return transactions.value.filter(tx => {
|
||||
const searchText = [
|
||||
tx.type,
|
||||
tx.status,
|
||||
tx.flow?.label,
|
||||
formatDate(tx.created_at),
|
||||
tx.amount?.toString()
|
||||
].filter(Boolean).join(' ').toLowerCase()
|
||||
return searchText.includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const tableDensityClass = computed(() => {
|
||||
return {
|
||||
'density-comfortable': tableDensity.value === 'comfortable',
|
||||
'density-compact': tableDensity.value === 'compact',
|
||||
'density-ultra': tableDensity.value === 'ultra-compact'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage-transactions-page pb-5">
|
||||
<br><br>
|
||||
|
||||
<div class="tf-container">
|
||||
<!-- Back Button & Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<BackButton
|
||||
:to="targetHash ? { page: 'ManageProductAdmin', query: { target: targetHash } } : { page: 'Home' }"
|
||||
/>
|
||||
<h4 v-if="!productData.name" class="fw_6 mb-0">{{ pageHeader }}</h4>
|
||||
<button
|
||||
v-if="!targetHash"
|
||||
@click="navigate({ page: 'AddTransaction' })"
|
||||
class="btn btn-primary btn-sm rounded-pill px-3 shadow-sm"
|
||||
>
|
||||
<i class="fas fa-plus me-1"></i> Record Transaction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Product Header Card -->
|
||||
<div v-if="productData.name" class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-3 mb-3 mb-md-0">
|
||||
<div class="product-header-photo">
|
||||
<FileImage
|
||||
v-if="productData.photourl"
|
||||
:src="productData.photourl"
|
||||
class="img-fluid rounded"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin"
|
||||
/>
|
||||
<img v-else src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" class="img-fluid rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h3 class="fw_6 mb-1">{{ productData.name }}</h3>
|
||||
<p class="mb-1 text-muted">
|
||||
Price: <strong>{{ formatCurrency(productData.price) }} / {{ productData.unitname || 'unit' }}</strong>
|
||||
</p>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<!-- Manage Product Button -->
|
||||
<button
|
||||
@click="goToManageProduct"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<i class="fas fa-briefcase"></i> Manage Product
|
||||
</button>
|
||||
|
||||
<!-- Store Transactions Button (if applicable) -->
|
||||
<button
|
||||
v-if="productData.is_from_store"
|
||||
@click="goToStoreTransactions"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
>
|
||||
<i class="fas fa-receipt"></i> Store Transactions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading && transactions.length === 0">
|
||||
<SkeletonTable :rows="10" :columns="5" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="filteredTransactions.length === 0 && !searchQuery" class="card shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-receipt fa-4x text-muted mb-3"></i>
|
||||
<h5>No Transactions Found</h5>
|
||||
<p class="text-muted">This product has no transaction history yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions Card -->
|
||||
<div class="toolbar-row d-flex align-items-center gap-3 mb-4">
|
||||
<div class="search-container flex-grow-1">
|
||||
<SearchBar
|
||||
v-model="searchQuery"
|
||||
placeholder="Search transactions by type, status, flow, date, amount..."
|
||||
/>
|
||||
</div>
|
||||
<div class="density-container flex-shrink-0">
|
||||
<TableDensityToggle v-model="tableDensity" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredTransactions.length === 0 && searchQuery" class="card shadow-sm border-0 rounded-20">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-search fa-4x text-muted mb-3"></i>
|
||||
<h5>No Results Found</h5>
|
||||
<p class="text-muted">No transactions match your search criteria.</p>
|
||||
<button @click="searchQuery = ''" class="btn btn-outline-primary mt-2">Clear Search</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card shadow-sm border-0 rounded-20" :data-table-density="tableDensity">
|
||||
<div class="card-header bg-transparent border-0 pt-4 px-4 d-flex justify-content-between align-items-center">
|
||||
<h5 class="fw_7 mb-0">{{ pageHeader }}</h5>
|
||||
<span class="text-muted small">{{ filteredTransactions.length }} transaction(s)</span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle density-table mb-0" :class="tableDensityClass">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Type</th>
|
||||
<th>Flow</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="transaction in filteredTransactions" :key="transaction.id">
|
||||
<td>{{ formatDate(transaction.created_at) }}</td>
|
||||
<td class="fw_6">{{ formatCurrency(transaction.amount) }}</td>
|
||||
<td><span class="badge bg-info">{{ transaction.type }}</span></td>
|
||||
<td>
|
||||
<span v-if="transaction.flow" class="badge" :class="{
|
||||
'bg-success': transaction.flow.value === 1,
|
||||
'bg-danger': transaction.flow.value === -1,
|
||||
'bg-secondary': transaction.flow.value === 0
|
||||
}">
|
||||
{{ transaction.flow.label }}
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="badge bg-success">{{ transaction.status }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.search-container {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 50%;
|
||||
}
|
||||
.density-container {
|
||||
flex: 1;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.density-table :deep(thead th) {
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
.density-table :deep(tbody td) {
|
||||
transition: padding 0.2s ease;
|
||||
}
|
||||
|
||||
.density-comfortable :deep(thead th) {
|
||||
padding: 1rem;
|
||||
}
|
||||
.density-comfortable :deep(tbody td) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.density-compact :deep(thead th) {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
.density-compact :deep(tbody td) {
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
.density-ultra :deep(thead th) {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.density-ultra :deep(tbody td) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.density-ultra :deep(.small) {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.density-ultra :deep(.smallest) {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-container {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.density-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,314 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, h } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import UpdateProductModal from '../Components/Market/UpdateProductModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, default: null },
|
||||
payload: { type: Object, default: null }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { role } = useAuth();
|
||||
const isBig3 = computed(() => ['ULTIMATE', 'SUPER_OPERATOR', 'OPERATOR'].includes(role.value));
|
||||
const modal = useModal();
|
||||
usePageTitle('Manage Product');
|
||||
|
||||
const product = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
const productHash = computed(() => props.target || props.payload?.product_hash || props.payload?.product_hashkey);
|
||||
const storeHash = computed(() => props.payload?.store_hash || props.payload?.store_hashkey);
|
||||
|
||||
const fetchDetails = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/View/Product/Details/data', {
|
||||
target: productHash.value,
|
||||
data: { store_hash: storeHash.value }
|
||||
});
|
||||
if (response.data && response.data.success) {
|
||||
product.value = response.data.data;
|
||||
} else {
|
||||
error.value = 'Failed to load product details';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = 'Error fetching product information';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openUpdateModal = (isStore = false) => {
|
||||
modal.open({
|
||||
title: isStore ? 'Manage Store Listing' : 'Edit Global Details',
|
||||
body: h(UpdateProductModal, {
|
||||
productHash: productHash.value,
|
||||
storeHash: isStore ? storeHash.value : null,
|
||||
onSaved: () => {
|
||||
modal.close();
|
||||
fetchDetails(); // Refresh the page data
|
||||
},
|
||||
onClose: () => modal.close()
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const goToGlobalEdit = () => {
|
||||
openUpdateModal(false);
|
||||
};
|
||||
|
||||
const goToStoreEdit = () => {
|
||||
openUpdateModal(true);
|
||||
};
|
||||
|
||||
onMounted(fetchDetails);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage-product-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<BackButton to="Home" />
|
||||
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<LoadingSpinner :show="loading" />
|
||||
<p class="mt-3 text-muted">Loading management console...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-danger mt-4">{{ error }}</div>
|
||||
|
||||
<template v-else-if="product">
|
||||
<div class="management-header mt-4">
|
||||
<div class="d-flex align-items-center gap-4">
|
||||
<div class="product-miniature shadow-sm">
|
||||
<FileImage :src="product.photourl && product.photourl[0] ? product.photourl[0] : ''"
|
||||
class="img-fluid rounded-lg" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="fw_8 mb-1">{{ product.name }}</h2>
|
||||
<div class="badge bg-soft-primary text-primary px-3 rounded-pill">
|
||||
{{ product.category }}
|
||||
</div>
|
||||
<div v-if="storeHash" class="badge bg-soft-info text-info px-3 rounded-pill ms-2">
|
||||
<i class="fas fa-store me-1"></i> Store Specific View
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mt-4">
|
||||
<!-- Global Management Card -->
|
||||
<div class="col-md-6">
|
||||
<div class="management-card global-card h-100 shadow-sm transition-hover"
|
||||
:class="{'locked-card': !isBig3}"
|
||||
@click="isBig3 ? goToGlobalEdit() : null">
|
||||
<div class="card-body p-4 position-relative">
|
||||
<div v-if="!isBig3" class="lock-overlay d-flex flex-column align-items-center justify-content-center">
|
||||
<div class="lock-icon mb-2">
|
||||
<i class="fas fa-lock fa-2x text-muted"></i>
|
||||
</div>
|
||||
<span class="badge bg-light text-dark shadow-sm">Admin Only</span>
|
||||
</div>
|
||||
<div class="icon-circle bg-soft-warning text-warning mb-3">
|
||||
<i class="fas fa-globe-asia fa-lg"></i>
|
||||
</div>
|
||||
<h4 class="fw_7">Global Details</h4>
|
||||
<p class="text-muted small">Edit primary product information, photos, categories, and base settings available across all markets.</p>
|
||||
<div class="stats-row mt-4">
|
||||
<div class="stat-item">
|
||||
<div class="label">Base Price</div>
|
||||
<div class="value">₱{{ product.price }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="label">Unit</div>
|
||||
<div class="value text-truncate" style="max-width: 80px;">{{ product.unitname }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn w-100 mt-4 rounded-xl fw_6" :class="isBig3 ? 'btn-outline-warning' : 'btn-light disabled'">
|
||||
{{ isBig3 ? 'Edit Global Data' : 'View Only' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Store Context Card -->
|
||||
<div v-if="storeHash" class="col-md-6">
|
||||
<div class="management-card store-card h-100 shadow-sm border-primary transition-hover" @click="goToStoreEdit">
|
||||
<div class="card-body p-4">
|
||||
<div class="icon-circle bg-soft-primary text-primary mb-3">
|
||||
<i class="fas fa-store-alt fa-lg"></i>
|
||||
</div>
|
||||
<h4 class="fw_7">Store Listing</h4>
|
||||
<p class="text-muted small">Manage how this product appears in the current store. Override pricing and update available stock.</p>
|
||||
<div class="stats-row mt-4">
|
||||
<div class="stat-item">
|
||||
<div class="label">Store Price</div>
|
||||
<div class="value text-primary">₱{{ product.store_price || product.price }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="label">Stock</div>
|
||||
<div class="value" :class="product.available > 0 ? 'text-success' : 'text-danger'">
|
||||
{{ product.available }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100 mt-4 rounded-xl fw_6 shadow-sm">
|
||||
Manage Store Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Actions / Insight Placeholder -->
|
||||
<div class="col-12 mt-4">
|
||||
<div class="insight-card p-4 rounded-xxl bg-light border">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h5 class="fw_7 mb-1">Product Insights</h5>
|
||||
<p class="text-muted small mb-0">Total sales and performance tracking coming soon.</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-white text-dark border rounded-pill px-3 py-2">
|
||||
<i class="fas fa-chart-line me-2 text-success"></i> Trending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.manage-product-page {
|
||||
background: #fbfcfe;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.management-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.product-miniature {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.locked-card {
|
||||
cursor: not-allowed;
|
||||
background: #f8f9fa;
|
||||
border: 1px dashed #dee2e6;
|
||||
}
|
||||
|
||||
.lock-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.4);
|
||||
z-index: 2;
|
||||
border-radius: inherit;
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.management-card {
|
||||
border-radius: 20px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.product-miniature img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.management-card {
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.management-card.global-card {
|
||||
border-bottom: 4px solid #f8d05e;
|
||||
}
|
||||
|
||||
.management-card.store-card {
|
||||
border-bottom: 4px solid #0085ff;
|
||||
}
|
||||
|
||||
.transition-hover:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.08) !important;
|
||||
}
|
||||
|
||||
.icon-circle {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bg-soft-warning { background: rgba(248, 208, 94, 0.1); }
|
||||
.bg-soft-primary { background: rgba(0, 133, 255, 0.1); }
|
||||
.bg-soft-info { background: rgba(0, 219, 255, 0.1); }
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: #889;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #2e3b4e;
|
||||
}
|
||||
|
||||
.rounded-lg { border-radius: 16px; }
|
||||
.rounded-xl { border-radius: 12px; }
|
||||
.rounded-xxl { border-radius: 20px; }
|
||||
|
||||
:global(.dark-mode) .management-header,
|
||||
:global(.dark-mode) .management-card {
|
||||
background: #1a1c20;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .stats-row {
|
||||
background: #24272c;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .stat-item .value {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,613 +0,0 @@
|
||||
<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>
|
||||
@@ -1,384 +0,0 @@
|
||||
<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 BackButton from '../Components/Core/BackButton.vue'
|
||||
import { useUserSettings } from '../composables/useUserSettings'
|
||||
import SearchableTableWrapper from '../Components/Core/SearchableTableWrapper.vue'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
const { isUltimate, isSuperOperator, isOperator, isStoreOwner, user } = useAuth()
|
||||
const { settings, updateSetting } = useUserSettings();
|
||||
usePageTitle('Store Management')
|
||||
|
||||
const stores = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const canModifyStore = (store) => {
|
||||
if (isStoreOwner.value && !!store.user_can_manage) return true
|
||||
return !!store.user_can_manage
|
||||
}
|
||||
|
||||
const firstPhoto = (v) => Array.isArray(v) ? (v[0] || '') : (v || '')
|
||||
|
||||
const fetchStores = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await axios.post('/Admin/Stores/List')
|
||||
if (response.data && response.data.success && Array.isArray(response.data.stores)) {
|
||||
stores.value = response.data.stores
|
||||
} else {
|
||||
error.value = 'Failed to load stores'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching stores:', err)
|
||||
error.value = 'Failed to load stores. Please try again.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filteredStores = computed(() => {
|
||||
if (!searchQuery.value) return stores.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return stores.value.filter(s =>
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q) ||
|
||||
s.subcategory?.toLowerCase().includes(q) ||
|
||||
s.address?.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const tableDensity = computed({
|
||||
get: () => settings.value.table_density || 'comfortable',
|
||||
set: (val) => updateSetting('table_density', val)
|
||||
});
|
||||
|
||||
const toggleStatus = async (store) => {
|
||||
if (!canModifyStore(store)) {
|
||||
modal.open({
|
||||
title: 'Permission Denied',
|
||||
body: 'You can only modify stores you own or manage.'
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.post('/Admin/Store/ToggleStatus', {
|
||||
target: store.hashkey
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
store.is_active = response.data.data.is_active
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling store status:', err)
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to update status'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteStore = (store) => {
|
||||
if (!canModifyStore(store)) {
|
||||
modal.open({
|
||||
title: 'Permission Denied',
|
||||
body: 'You can only delete stores you own or manage.'
|
||||
})
|
||||
return
|
||||
}
|
||||
modal.yesNoModal({
|
||||
title: 'Confirm Deletion',
|
||||
body: `Are you sure you want to permanently delete "${store.name}"? This action cannot be undone.`,
|
||||
yesText: 'Delete',
|
||||
noText: 'Cancel',
|
||||
yesClass: 'btn-danger',
|
||||
onYes: async () => {
|
||||
try {
|
||||
const response = await axios.post('/Admin/Store/Delete', {
|
||||
target: store.hashkey
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
stores.value = stores.value.filter(s => s.hashkey !== store.hashkey)
|
||||
modal.open({
|
||||
title: 'Success',
|
||||
body: 'Store deleted successfully.',
|
||||
footer: h('button', { class: 'btn btn-primary', onClick: modal.close }, 'OK')
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting store:', err)
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to delete store: ' + (err.response?.data?.message || err.message),
|
||||
footer: h('button', { class: 'btn btn-secondary', onClick: modal.close }, 'Close')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const editStore = (store) => {
|
||||
if (!canModifyStore(store)) {
|
||||
modal.open({
|
||||
title: 'Permission Denied',
|
||||
body: 'You can only edit stores you own or manage.'
|
||||
})
|
||||
return
|
||||
}
|
||||
navigate({ page: 'EditStoreUltimate', props: { target: store.hashkey } })
|
||||
}
|
||||
|
||||
const viewStore = (store) => {
|
||||
navigate({ page: 'ViewStoreMarket', props: { target: store.hashkey } })
|
||||
}
|
||||
|
||||
const createStore = () => {
|
||||
navigate({ page: 'CreateStore' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStores()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="manage-stores-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<BackButton to="Home" />
|
||||
|
||||
<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 Stores</h3>
|
||||
<button @click="createStore" 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 Store
|
||||
</button>
|
||||
<button v-if="isUltimate || isSuperOperator || isOperator" @click="navigate({ page: 'BatchAddStores' })" 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">
|
||||
{{ filteredStores.length }} Stores
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Searchable Table Wrapper -->
|
||||
<SearchableTableWrapper
|
||||
v-model:searchValue="searchQuery"
|
||||
v-model:densityValue="tableDensity"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
:empty="filteredStores.length === 0"
|
||||
searchPlaceholder="Search stores..."
|
||||
emptyIcon="fas fa-store-slash"
|
||||
emptyTitle="No stores found"
|
||||
emptyMessage="Try adjusting your search criteria or create a new store"
|
||||
:skeletonRows="8"
|
||||
:skeletonColumns="7"
|
||||
>
|
||||
<template #empty-state>
|
||||
<i class="fas fa-store-slash fa-4x text-muted opacity-25 mb-3"></i>
|
||||
<h5>No stores found</h5>
|
||||
<p class="text-muted">Try adjusting your search criteria or create a new store</p>
|
||||
<button @click="createStore" class="btn btn-primary mt-3">Create First Store</button>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Logo</th>
|
||||
<th>Store Info</th>
|
||||
<th>Category</th>
|
||||
<th>Address</th>
|
||||
<th>Cooperatives</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="store in filteredStores" :key="store.hashkey">
|
||||
<td>
|
||||
<div class="store-thumb">
|
||||
<FileImage :src="firstPhoto(store.photourl)"
|
||||
class="img-fluid rounded" alt="Store" 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" style="color: var(--text-primary); cursor: pointer;" @click="viewStore(store)">{{ store.name }}</div>
|
||||
<div class="text-muted small text-truncate" style="max-width: 200px;">{{ store.description }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="small">{{ store.category || 'N/A' }}</div>
|
||||
<div class="text-muted smallest">{{ store.subcategory || 'N/A' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="small text-truncate" style="max-width: 150px;">{{ store.address }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="store.cooperatives && store.cooperatives.length" class="d-flex flex-wrap gap-1" style="max-width: 200px;">
|
||||
<span v-for="coop in store.cooperatives" :key="coop.hashkey"
|
||||
class="badge bg-soft-primary text-primary small">
|
||||
{{ coop.name }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="text-muted smallest">—</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="store.is_active" @change="toggleStatus(store)"
|
||||
:disabled="!canModifyStore(store)">
|
||||
</div>
|
||||
<div class="text-center smallest mt-1" :class="store.is_active ? 'text-success' : 'text-danger'">
|
||||
{{ store.is_active ? 'Active' : 'Inactive' }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button @click="viewStore(store)" class="btn btn-sm btn-icon btn-outline-info" title="View in Market">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button v-if="canModifyStore(store)" @click="navigate({ page: 'AddProductsToStore', props: { target: store.hashkey } })" class="btn btn-sm btn-icon btn-outline-success" title="Assign Products to Store">
|
||||
<i class="fas fa-boxes"></i>
|
||||
</button>
|
||||
<button v-if="canModifyStore(store)" @click="editStore(store)" class="btn btn-sm btn-icon btn-outline-primary" title="Edit">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button v-if="canModifyStore(store)" @click="deleteStore(store)" 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-stores-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.store-table-container {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.store-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f0f3f6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.store-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);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .manage-stores-page {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-table-container {
|
||||
background: var(--bg-card) !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .table thead th {
|
||||
background: var(--bg-tertiary) !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .table tbody td {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .no-results {
|
||||
background: var(--bg-card) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,355 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('My Stores');
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import SearchBar from '../Components/Core/Search/SearchBar.vue';
|
||||
import StoreCard from '../Components/Market/StoreCard.vue';
|
||||
import StoreListSkeleton from '../Components/Core/Skeleton/StoreListSkeleton.vue';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { user, isLoggedIn, isUltimate, isSuperOperator, isOperator } = useAuth();
|
||||
const isBig3 = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
|
||||
const modal = useModal();
|
||||
|
||||
const stores = ref([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
const viewMode = ref('grid'); // 'grid' or 'list'
|
||||
|
||||
const fetchStores = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/ListStores/MyStores/data');
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
stores.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch my stores:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredStores = computed(() => {
|
||||
if (!searchQuery.value) return stores.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return stores.value.filter(s =>
|
||||
s.name?.toLowerCase().includes(q) ||
|
||||
s.category?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const viewStore = (store) => {
|
||||
navigate({
|
||||
page: 'ViewStoreMarket',
|
||||
props: { target: store.hashkey }
|
||||
});
|
||||
};
|
||||
|
||||
const editStore = (store) => {
|
||||
navigate({
|
||||
page: 'EditStoreUltimate',
|
||||
props: { target: store.hashkey }
|
||||
});
|
||||
};
|
||||
|
||||
const assignProducts = (store) => {
|
||||
navigate({
|
||||
page: 'AddProductsToStore',
|
||||
props: { target: store.hashkey }
|
||||
});
|
||||
};
|
||||
|
||||
const goToBrowseStores = () => {
|
||||
navigate({ page: 'ListStores' });
|
||||
};
|
||||
|
||||
const getRoleBadgeClass = (role) => {
|
||||
switch (role) {
|
||||
case 'owner': return 'badge-owner';
|
||||
case 'manager': return 'badge-manager';
|
||||
default: return 'badge-viewer';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleLabel = (role) => {
|
||||
switch (role) {
|
||||
case 'owner': return 'Owner';
|
||||
case 'manager': return 'Manager';
|
||||
default: return 'Viewer';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchStores();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-stores-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<BackButton to="Home" />
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3 mt-3">
|
||||
<h3 class="fw_6 mb-0">My Stores</h3>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
||||
{{ filteredStores.length }} Stores
|
||||
</div>
|
||||
<div class="view-toggle btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-outline-secondary'" @click="viewMode = 'grid'">
|
||||
<i class="fas fa-th-large"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm" :class="viewMode === 'list' ? 'btn-primary' : 'btn-outline-secondary'" @click="viewMode = 'list'">
|
||||
<i class="fas fa-list"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBar v-model="searchQuery" placeholder="Search your stores..." class="mb-4" />
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<StoreListSkeleton :count="4" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredStores.length === 0" class="text-center py-5 no-results">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin" style="width: 120px; opacity: 0.3;" class="mb-3">
|
||||
<h5>No stores yet</h5>
|
||||
<p class="text-muted">You don't have any stores assigned to you.</p>
|
||||
<button @click="goToBrowseStores" class="btn btn-primary rounded-pill px-4 mt-2">
|
||||
Browse All Stores
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grid View -->
|
||||
<div v-else-if="viewMode === 'grid'" class="row g-3 store-grid">
|
||||
<div v-for="store in filteredStores" :key="store.hashkey" class="col-6 col-md-4">
|
||||
<div class="store-card-wrapper" @click="viewStore(store)">
|
||||
<StoreCard
|
||||
:name="store.name"
|
||||
:category="store.category"
|
||||
:image="store.photourl || ''"
|
||||
/>
|
||||
<span class="role-badge" :class="getRoleBadgeClass(store.role)">
|
||||
{{ getRoleLabel(store.role) }}
|
||||
</span>
|
||||
<div class="store-card-actions" @click.stop>
|
||||
<button @click="viewStore(store)" class="btn btn-sm btn-icon btn-outline-info" title="View Store">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button v-if="store.role === 'owner' || isBig3" @click="assignProducts(store)" class="btn btn-sm btn-icon btn-outline-success" title="Add Products">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List View -->
|
||||
<div v-else class="store-list-view">
|
||||
<div v-for="store in filteredStores" :key="store.hashkey" class="store-list-item" @click="viewStore(store)">
|
||||
<div class="store-list-avatar">
|
||||
<FileImage
|
||||
:src="store.photourl || ''"
|
||||
class="avatar-img"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin"
|
||||
/>
|
||||
</div>
|
||||
<div class="store-list-info">
|
||||
<h6 class="fw_6 mb-0">{{ store.name }}</h6>
|
||||
<p class="text-muted small mb-0">{{ store.category || 'General' }}</p>
|
||||
</div>
|
||||
<div class="store-list-actions">
|
||||
<span class="role-badge-sm" :class="getRoleBadgeClass(store.role)">
|
||||
{{ getRoleLabel(store.role) }}
|
||||
</span>
|
||||
<button @click.stop="viewStore(store)" class="btn btn-sm btn-icon btn-outline-info" title="View Store">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button v-if="store.role === 'owner' || isBig3" @click.stop="assignProducts(store)" class="btn btn-sm btn-icon btn-outline-success" title="Add Products">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse All Stores Link -->
|
||||
<div v-if="!loading && filteredStores.length > 0" class="text-center mt-4">
|
||||
<button @click="goToBrowseStores" class="btn btn-outline-primary rounded-pill px-4">
|
||||
<i class="fas fa-store me-2"></i> Browse All Stores
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.my-stores-page {
|
||||
background: var(--bg-primary);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.badge.bg-soft-primary {
|
||||
background-color: rgba(66, 185, 131, 0.1);
|
||||
}
|
||||
|
||||
.store-grid {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.store-card-wrapper {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.store-card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
padding: 6px 4px 2px;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.badge-owner {
|
||||
background: rgba(16, 185, 129, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-manager {
|
||||
background: rgba(99, 102, 241, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.badge-viewer {
|
||||
background: rgba(107, 114, 128, 0.85);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.role-badge-sm {
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
background: #f8f9fa;
|
||||
border-radius: 20px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
/* List View */
|
||||
.store-list-view {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.store-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.store-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.store-list-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: #f0f3f6;
|
||||
}
|
||||
|
||||
.store-list-avatar .avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.store-list-info {
|
||||
flex: 1;
|
||||
margin-left: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-list-info h6 {
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.store-list-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.view-toggle .btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .no-results {
|
||||
background: #1a1c20;
|
||||
border-color: #2c3e50;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-list-item {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-list-item:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,364 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '@/composables/Core/usePageTitle';
|
||||
import { useNavigate } from '@/composables/Core/useNavigate';
|
||||
import { useUIStore } from '@/stores/ui';
|
||||
import { injectAmount, generateQrDataUrl } from '@/composables/useQrph';
|
||||
|
||||
usePageTitle('My Wallet');
|
||||
const { navigate } = useNavigate();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const wallet = ref({ balance: 0, credit: 0, history: [] });
|
||||
const isLoading = ref(true);
|
||||
const isProcessing = ref(false);
|
||||
const topUpAmount = ref(100);
|
||||
const showTopUpModal = ref(false);
|
||||
const topUpTab = ref('amount'); // 'amount' | 'qrph'
|
||||
|
||||
const qrph = ref(null); // raw base QRPH string (no amount)
|
||||
const qrphDecoded = ref(null); // decoded info object
|
||||
const qrphImageUrl = ref(null); // stored static image (no amount)
|
||||
const qrphAmountDataUrl = ref(null); // generated QR with amount injected
|
||||
const isGeneratingQr = ref(false);
|
||||
|
||||
const isProceedDisabled = computed(() => {
|
||||
return isProcessing.value || !topUpAmount.value || topUpAmount.value <= 0 || !uiStore.top_up_enabled;
|
||||
});
|
||||
|
||||
// Regenerate QR with amount whenever the amount changes or the tab switches to qrph
|
||||
const rebuildAmountQr = async () => {
|
||||
if (!qrph.value || topUpTab.value !== 'qrph') return;
|
||||
const amount = parseFloat(topUpAmount.value);
|
||||
if (!amount || amount <= 0) {
|
||||
// No amount — just render the base QRPH string as QR
|
||||
isGeneratingQr.value = true;
|
||||
try {
|
||||
qrphAmountDataUrl.value = await generateQrDataUrl(qrph.value);
|
||||
} finally {
|
||||
isGeneratingQr.value = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
isGeneratingQr.value = true;
|
||||
try {
|
||||
const modified = injectAmount(qrph.value, amount);
|
||||
qrphAmountDataUrl.value = await generateQrDataUrl(modified);
|
||||
} finally {
|
||||
isGeneratingQr.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch([topUpTab, topUpAmount], ([tab]) => {
|
||||
if (tab === 'qrph') rebuildAmountQr();
|
||||
}, { immediate: false });
|
||||
|
||||
const fetchWalletData = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Financial/Wallet/Get');
|
||||
if (response.data.success) {
|
||||
wallet.value = {
|
||||
balance: response.data.balance,
|
||||
credit: response.data.credit,
|
||||
history: response.data.history
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch wallet data:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchQrph = async () => {
|
||||
try {
|
||||
const response = await axios.post('/Financial/Qrph/Get');
|
||||
if (response.data.success && response.data.qrph) {
|
||||
qrph.value = response.data.qrph;
|
||||
qrphDecoded.value = response.data.decoded;
|
||||
qrphImageUrl.value = response.data.image_url || null;
|
||||
}
|
||||
} catch (error) {
|
||||
// QRPH not configured — silent
|
||||
}
|
||||
};
|
||||
|
||||
const openTopUpModal = () => {
|
||||
// Default to QRPH tab when a payment code is configured
|
||||
topUpTab.value = qrph.value ? 'qrph' : 'amount';
|
||||
qrphAmountDataUrl.value = null;
|
||||
showTopUpModal.value = true;
|
||||
if (qrph.value) rebuildAmountQr();
|
||||
};
|
||||
|
||||
const handleTopUp = async () => {
|
||||
if (isProceedDisabled.value) return;
|
||||
|
||||
isProcessing.value = true;
|
||||
try {
|
||||
if (window.toastr) window.toastr.info('Processing top-up...');
|
||||
const response = await axios.post('/Financial/Credit/TopUp', {
|
||||
amount: topUpAmount.value,
|
||||
method: 'GCASH'
|
||||
});
|
||||
if (response.data.success) {
|
||||
if (window.toastr) window.toastr.success('Top-up successful!');
|
||||
showTopUpModal.value = false;
|
||||
fetchWalletData();
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.toastr) window.toastr.error('Top-up failed');
|
||||
} finally {
|
||||
isProcessing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchWalletData();
|
||||
fetchQrph();
|
||||
uiStore.refreshSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="my-wallet pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<!-- Balance Card -->
|
||||
<div class="card border-0 shadow-lg rounded-25 wallet-gradient text-white p-4 mb-4 position-relative overflow-hidden">
|
||||
<div class="position-absolute top-0 end-0 p-4 opacity-10">
|
||||
<i class="fas fa-wallet fa-6x rotate-15"></i>
|
||||
</div>
|
||||
<div class="position-relative">
|
||||
<p class="smallest fw_6 text-uppercase opacity-75 mb-1">Total Balance</p>
|
||||
<h1 class="fw_8 mb-3">₱ {{ Number(wallet.balance).toLocaleString(undefined, {minimumFractionDigits: 2}) }}</h1>
|
||||
|
||||
<div class="d-flex gap-4">
|
||||
<div>
|
||||
<p class="smallest fw_6 text-uppercase opacity-75 mb-0">Total Credit</p>
|
||||
<p class="fw_7 mb-0">₱ {{ Number(wallet.credit).toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Grid -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-6">
|
||||
<button @click="openTopUpModal"
|
||||
:disabled="!uiStore.top_up_enabled && !qrph"
|
||||
class="btn btn-white shadow-sm rounded-20 w-100 py-3 transition-all hover-up d-flex flex-column align-items-center gap-2">
|
||||
<div :class="[uiStore.top_up_enabled ? 'bg-soft-primary text-primary' : 'bg-light text-muted', 'rounded-circle p-2']">
|
||||
<i class="fas fa-plus"></i>
|
||||
</div>
|
||||
<span class="smallest fw_7">{{ !uiStore.top_up_enabled && !qrph ? 'Top Up (Disabled)' : 'Top Up' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<button @click="navigate({ page: 'TransferCredit' })" class="btn btn-white shadow-sm rounded-20 w-100 py-3 transition-all hover-up d-flex flex-column align-items-center gap-2">
|
||||
<div class="bg-soft-info text-info rounded-circle p-2">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
</div>
|
||||
<span class="smallest fw_7">Send Money</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="transactions">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="fw_7 mb-0">Recent Activity</h5>
|
||||
<button class="btn btn-link py-0 text-primary fw_6 smallest text-decoration-none">View All</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary spinner-border-sm" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="wallet.history.length === 0" class="text-center py-5 bg-light rounded-25 opacity-75">
|
||||
<i class="fas fa-receipt fa-3x text-muted mb-3 opacity-25"></i>
|
||||
<p class="text-muted smaller">No transactions yet</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="transaction-list">
|
||||
<div v-for="txn in wallet.history" :key="txn.hashkey" class="card border-0 shadow-sm rounded-20 p-3 mb-2 hover-card">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div :class="[txn.flow === 'IN' ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger', 'rounded-circle p-2 flex-shrink-0']" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
|
||||
<i :class="txn.flow === 'IN' ? 'fas fa-arrow-down' : 'fas fa-arrow-up'"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<h6 class="fw_7 mb-0 text-dark text-truncate">{{ txn.description }}</h6>
|
||||
<p class="smallest text-muted mb-0">{{ new Date(txn.created_at).toLocaleDateString() }} • {{ txn.transaction_type }}</p>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<h6 :class="[txn.flow === 'IN' ? 'text-success' : 'text-danger', 'fw_7 mb-0']">
|
||||
{{ txn.flow === 'IN' ? '+' : '-' }} ₱{{ Number(txn.amount).toLocaleString() }}
|
||||
</h6>
|
||||
<p class="smallest text-muted mb-0">₱{{ Number(txn.balance_after).toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Up Modal -->
|
||||
<div v-if="showTopUpModal" class="custom-modal-overlay" @click.self="showTopUpModal = false">
|
||||
<div class="card border-0 shadow-lg rounded-25 p-4 w-100 mx-3 animate-slide-up" style="max-width: 420px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="fw_7 mb-0">Top Up Credit</h5>
|
||||
<button @click="showTopUpModal = false" class="btn btn-sm btn-light rounded-circle" style="width:32px;height:32px;padding:0;">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs (only show QRPH tab if a code is configured) -->
|
||||
<div v-if="qrph" class="d-flex gap-2 mb-4 p-1 rounded-pill" style="background:var(--bg-secondary, #f5f5f5);">
|
||||
<button @click="topUpTab = 'amount'"
|
||||
:class="['btn rounded-pill flex-fill py-2 smallest fw_7 transition-all', topUpTab === 'amount' ? 'btn-primary shadow-sm' : 'btn-link text-muted text-decoration-none']">
|
||||
<i class="fas fa-coins me-1"></i> Enter Amount
|
||||
</button>
|
||||
<button @click="topUpTab = 'qrph'; rebuildAmountQr()"
|
||||
:class="['btn rounded-pill flex-fill py-2 smallest fw_7 transition-all', topUpTab === 'qrph' ? 'btn-primary shadow-sm' : 'btn-link text-muted text-decoration-none']">
|
||||
<i class="fas fa-qrcode me-1"></i> Scan to Pay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Amount Tab -->
|
||||
<div v-if="topUpTab === 'amount'">
|
||||
<p class="smallest text-muted text-center mb-4">Select an amount to add to your balance</p>
|
||||
|
||||
<div class="row g-2 mb-4">
|
||||
<div v-for="amt in [100, 200, 500, 1000]" :key="amt" class="col-6">
|
||||
<button
|
||||
@click="topUpAmount = amt"
|
||||
:class="[topUpAmount === amt ? 'btn-primary' : 'btn-outline-primary', 'btn rounded-pill w-100 py-2 fw_7']">
|
||||
₱ {{ amt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label class="smallest fw_6 text-muted mb-1">Custom Amount</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-0 rounded-start-pill ps-3">₱</span>
|
||||
<input v-model="topUpAmount" type="number" class="form-control bg-light border-0 rounded-end-pill pe-3 fw_7" placeholder="Enter amount">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button @click="showTopUpModal = false" class="btn btn-light rounded-pill flex-fill py-2 fw_6" :disabled="isProcessing">Cancel</button>
|
||||
<button @click="handleTopUp" :disabled="isProceedDisabled" class="btn btn-primary rounded-pill flex-fill py-2 fw_7 shadow-sm d-flex align-items-center justify-content-center gap-2">
|
||||
<i v-if="isProcessing" class="fas fa-spinner fa-spin"></i>
|
||||
{{ isProcessing ? 'Processing' : 'Proceed' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QRPH / Scan to Pay Tab -->
|
||||
<div v-else-if="topUpTab === 'qrph'" class="text-center">
|
||||
|
||||
<!-- Merchant Info Banner -->
|
||||
<div v-if="qrphDecoded" class="d-flex align-items-center gap-2 mb-3 p-2 rounded-20" style="background:var(--bg-secondary,#f5f5f5);">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||||
style="width:36px;height:36px;background:rgba(83,61,234,0.12);">
|
||||
<i class="fas fa-university" style="color:#533dea;font-size:14px;"></i>
|
||||
</div>
|
||||
<div class="text-start">
|
||||
<p class="mb-0 fw_7" style="font-size:13px;">{{ qrphDecoded.merchant_name || 'Merchant' }}</p>
|
||||
<p class="mb-0 text-muted" style="font-size:11px;">
|
||||
{{ qrphDecoded.merchant_account?.network || 'InstaPay' }}
|
||||
<span v-if="qrphDecoded.merchant_account?.account"> · {{ qrphDecoded.merchant_account.account }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount selector (compact, inside QR tab) -->
|
||||
<div class="mb-3">
|
||||
<p class="smallest text-muted mb-2">Set amount to embed in QR <span class="text-muted opacity-50">(optional)</span></p>
|
||||
<div class="d-flex gap-2 justify-content-center flex-wrap mb-2">
|
||||
<button v-for="amt in [100, 200, 500, 1000]" :key="amt"
|
||||
@click="topUpAmount = amt; rebuildAmountQr()"
|
||||
:class="['btn btn-sm rounded-pill fw_7', topUpAmount === amt ? 'btn-primary' : 'btn-outline-primary']"
|
||||
style="font-size:12px;min-width:56px;">
|
||||
₱{{ amt }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mx-auto" style="max-width:180px;">
|
||||
<span class="input-group-text bg-light border-0 rounded-start-pill ps-3 smallest">₱</span>
|
||||
<input v-model="topUpAmount" type="number" min="1"
|
||||
class="form-control bg-light border-0 rounded-end-pill pe-3 fw_7 smallest"
|
||||
placeholder="Custom">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Image: generated client-side with amount injected -->
|
||||
<div class="qrph-qr-wrapper mx-auto mb-2" style="background:#fff;border-radius:16px;padding:12px;display:inline-block;box-shadow:0 2px 12px rgba(0,0,0,0.08);">
|
||||
<div v-if="isGeneratingQr" style="width:220px;height:220px;display:flex;align-items:center;justify-content:center;">
|
||||
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||
</div>
|
||||
<img v-else-if="qrphAmountDataUrl"
|
||||
:src="qrphAmountDataUrl"
|
||||
alt="QRPH Payment Code"
|
||||
class="rounded-15"
|
||||
style="width:220px;height:220px;object-fit:contain;display:block;" />
|
||||
<img v-else-if="qrphImageUrl"
|
||||
:src="qrphImageUrl"
|
||||
alt="QRPH Payment Code"
|
||||
class="rounded-15"
|
||||
style="width:220px;height:220px;object-fit:contain;display:block;" />
|
||||
</div>
|
||||
|
||||
<!-- Amount badge below QR -->
|
||||
<div v-if="topUpAmount > 0" class="mb-3">
|
||||
<span class="badge rounded-pill px-3 py-2 fw_7" style="background:rgba(83,61,234,0.12);color:#533dea;font-size:14px;">
|
||||
₱ {{ Number(topUpAmount).toLocaleString(undefined, {minimumFractionDigits: 2}) }}
|
||||
</span>
|
||||
<p class="smallest text-muted mt-1 mb-0">embedded in QR — your banking app will pre-fill this amount</p>
|
||||
</div>
|
||||
|
||||
<p class="smallest text-muted mb-3">
|
||||
<i class="fas fa-info-circle me-1 text-primary"></i>
|
||||
Scan with GCash, Maya, GoTyme, or any InstaPay app · After payment, contact admin with your reference number.
|
||||
</p>
|
||||
|
||||
<button @click="showTopUpModal = false" class="btn btn-light rounded-pill w-100 py-2 fw_6">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-25 { border-radius: 25px; }
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.wallet-gradient {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #1e40af 100%);
|
||||
}
|
||||
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
|
||||
.bg-soft-info { background-color: rgba(0, 184, 217, 0.1); }
|
||||
.bg-soft-success { background-color: rgba(40, 167, 69, 0.1); }
|
||||
.bg-soft-danger { background-color: rgba(220, 53, 69, 0.1); }
|
||||
.text-info { color: #00B8D9 !important; }
|
||||
.rotate-15 { transform: rotate(-15deg); }
|
||||
.hover-up:hover { transform: translateY(-3px); }
|
||||
.hover-card:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.05) !important; }
|
||||
.smallest { font-size: 0.75rem; }
|
||||
.smaller { font-size: 0.85rem; }
|
||||
|
||||
.custom-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-up { animation: slideUp 0.3s ease-out; }
|
||||
</style>
|
||||
@@ -1,206 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Photo Viewer');
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useFileBlobCache } from '../composables/useFileBlobCache'
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, default: null }
|
||||
})
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
const { getFile } = useFileBlobCache()
|
||||
|
||||
// Data state
|
||||
const imageUrl = ref('')
|
||||
const photoHash = ref('')
|
||||
const isLoading = ref(true)
|
||||
|
||||
// Initialize component
|
||||
onMounted(async () => {
|
||||
document.title = 'Photo Viewer'
|
||||
|
||||
// Get photo hash from prop first, then fallback to query params for direct URL access
|
||||
const urlParams = new URL(window.location.href).searchParams
|
||||
photoHash.value = props.target || urlParams.get('hash') || urlParams.get('photo_hash')
|
||||
|
||||
if (!photoHash.value) {
|
||||
console.error('Photo hash not found')
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
imageUrl.value = await getFile(photoHash.value)
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
// Go back
|
||||
const goBack = () => {
|
||||
window.history.back() || navigate({ page: 'Home' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="photo-viewer-page min-vh-100 d-flex flex-column">
|
||||
<div class="viewer-header p-3 d-flex align-items-center justify-content-between">
|
||||
<BackButton
|
||||
fallback="Home"
|
||||
className="photo-viewer-back"
|
||||
text=""
|
||||
/>
|
||||
<h5 class="m-0 fw_6 text-white">Photo Details</h5>
|
||||
<div style="width: 40px;"></div> <!-- Spacer for balance -->
|
||||
</div>
|
||||
|
||||
<div class="viewer-content flex-grow-1 d-flex align-items-center justify-content-center p-3">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Display -->
|
||||
<transition name="zoom">
|
||||
<div v-if="!isLoading && imageUrl" class="photo-frame shadow-2xl">
|
||||
<img
|
||||
:src="imageUrl"
|
||||
alt="Viewed Photo"
|
||||
class="full-photo"
|
||||
>
|
||||
<div class="photo-actions">
|
||||
<a :href="imageUrl" :download="`photo-${photoHash}.jpg`" class="btn-action">
|
||||
<i class="fas fa-download"></i> Save
|
||||
</a>
|
||||
<button @click="goBack" class="btn-action">
|
||||
<i class="fas fa-times"></i> Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="!isLoading && !imageUrl" class="text-center text-muted">
|
||||
<i class="fas fa-exclamation-circle fa-4x mb-3 opacity-20"></i>
|
||||
<h5>Unable to display photo</h5>
|
||||
<button @click="goBack" class="btn btn-primary mt-3">Go Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.photo-viewer-page {
|
||||
background: #000;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.viewer-content {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.photo-frame {
|
||||
max-width: 95vw;
|
||||
max-height: 85vh;
|
||||
position: relative;
|
||||
background: #111;
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.full-photo {
|
||||
max-width: 100%;
|
||||
max-height: 85vh;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.photo-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px;
|
||||
background: linear-gradient(transparent, rgba(0,0,0,0.8));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.photo-frame:hover .photo-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(5px);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 8px 20px;
|
||||
border-radius: 30px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.zoom-enter-active, .zoom-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.zoom-enter-from, .zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
:deep(.photo-viewer-back) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.photo-viewer-back .icon-container) {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.photo-actions {
|
||||
opacity: 1; /* Always show buttons on mobile */
|
||||
padding: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,372 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, h } from 'vue';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
usePageTitle('POS Access Keys');
|
||||
const modal = useModal();
|
||||
const { isUltimate, isSuperOperator, isOperator } = useAuth();
|
||||
|
||||
const isAdmin = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
|
||||
|
||||
const keys = ref([]);
|
||||
const stores = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const newKey = ref({
|
||||
name: '',
|
||||
store_hash: '',
|
||||
expires_at: ''
|
||||
});
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredKeys = computed(() => {
|
||||
if (!searchQuery.value) return keys.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return keys.value.filter(k =>
|
||||
k.name.toLowerCase().includes(q) ||
|
||||
(k.store?.name || '').toLowerCase().includes(q) ||
|
||||
(k.store?.owner?.name || '').toLowerCase().includes(q) ||
|
||||
(k.store?.owner?.nickname || '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const fetchKeys = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/api/pos/access-keys/list');
|
||||
keys.value = response.data.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch keys:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStores = async () => {
|
||||
try {
|
||||
// Admin roles see all stores; store owner/manager see only their own
|
||||
const endpoint = isAdmin.value ? '/ListStores/List/data' : '/ListStores/MyStores/data';
|
||||
const response = await axios.post(endpoint);
|
||||
stores.value = response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stores:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createKey = async () => {
|
||||
if (!newKey.value.name || !newKey.value.store_hash) {
|
||||
modal.open({ title: 'Error', body: 'Please fill in all fields' });
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: newKey.value.name,
|
||||
store_hash: newKey.value.store_hash,
|
||||
};
|
||||
if (newKey.value.expires_at) {
|
||||
payload.expires_at = newKey.value.expires_at;
|
||||
}
|
||||
const response = await axios.post('/api/pos/access-keys/create', payload);
|
||||
if (response.data.success) {
|
||||
modal.quickDismiss({ title: 'Success', body: 'Access key created' });
|
||||
newKey.value = { name: '', store_hash: '', expires_at: '' };
|
||||
await fetchKeys();
|
||||
}
|
||||
} catch (error) {
|
||||
modal.open({ title: 'Error', body: 'Failed to create key' });
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKey = (hashkey) => {
|
||||
modal.yesNoModal({
|
||||
title: 'Confirm Delete',
|
||||
body: 'Are you sure you want to delete this access key?',
|
||||
onYes: async () => {
|
||||
try {
|
||||
await axios.post('/api/pos/access-keys/delete', { target: hashkey });
|
||||
await fetchKeys();
|
||||
} catch (error) {
|
||||
modal.open({ title: 'Error', body: 'Failed to delete key' });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toggleStatus = async (hashkey) => {
|
||||
try {
|
||||
await axios.post('/api/pos/access-keys/toggle', { target: hashkey });
|
||||
await fetchKeys();
|
||||
} catch (error) {
|
||||
modal.open({ title: 'Error', body: 'Failed to update status' });
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
modal.quickDismiss({ title: 'Copied', body: 'Access key copied to clipboard' });
|
||||
};
|
||||
|
||||
const showQrCode = (accessKey) => {
|
||||
const url = getPosUrl(accessKey);
|
||||
modal.open({
|
||||
title: 'POS Access QR Code',
|
||||
body: h('div', { class: 'text-center p-3' }, [
|
||||
h('div', { class: 'bg-white p-3 d-inline-block rounded shadow-sm border mb-3' }, [
|
||||
h('img', {
|
||||
src: `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(url)}`,
|
||||
style: { width: '250px', height: '250px' },
|
||||
alt: 'POS Access QR Code'
|
||||
})
|
||||
]),
|
||||
h('div', { class: 'fw_6 text-dark mb-1' }, 'Scan to Access POS Terminal'),
|
||||
h('div', { class: 'small text-muted mb-3' }, 'Use a mobile device or tablet to quickly open this terminal'),
|
||||
h('div', { class: 'p-2 bg-light rounded small text-break border' }, url)
|
||||
])
|
||||
});
|
||||
};
|
||||
|
||||
const getPosUrl = (accessKey) => {
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}/pos?key=${accessKey}`;
|
||||
};
|
||||
|
||||
const canShare = ref(false);
|
||||
|
||||
const shareKey = async (accessKey, terminalName) => {
|
||||
const url = getPosUrl(accessKey);
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'POS Terminal Access',
|
||||
text: `Scan or click to open POS Terminal: ${terminalName}`,
|
||||
url: url
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('Error sharing:', error);
|
||||
modal.open({ title: 'Error', body: 'Could not open share dialog.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openPosInNewTab = (accessKey) => {
|
||||
window.open(getPosUrl(accessKey), '_blank');
|
||||
};
|
||||
|
||||
const isKeyExpired = (key) => {
|
||||
if (!key.expires_at) return false;
|
||||
return new Date(key.expires_at) < new Date();
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Never';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchKeys();
|
||||
fetchStores();
|
||||
canShare.value = !!navigator.share;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<BackButton />
|
||||
<h2 class="mb-0 ms-3 fw_7">POS Access Key Management</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Create New Key -->
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card shadow-sm border-0 rounded-xl">
|
||||
<div class="card-header bg-white border-0 pt-4 px-4 pb-0">
|
||||
<h5 class="fw_6 mb-0">Create New Terminal Key</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw_6 text-muted">Terminal Name (e.g. Counter 1)</label>
|
||||
<input v-model="newKey.name" type="text" class="form-control rounded-pill" placeholder="Enter terminal name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw_6 text-muted">Select Store</label>
|
||||
<select v-model="newKey.store_hash" class="form-select rounded-pill">
|
||||
<option value="" disabled>Select a store</option>
|
||||
<option v-for="store in stores" :key="store.hashkey" :value="store.hashkey">
|
||||
{{ store.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw_6 text-muted">Expiry Date (optional)</label>
|
||||
<input v-model="newKey.expires_at" type="datetime-local" class="form-control rounded-pill">
|
||||
<div class="form-text small">Leave empty for no expiration</div>
|
||||
</div>
|
||||
<button @click="createKey" class="btn btn-primary w-100 rounded-pill py-2 fw_6" :disabled="isSaving">
|
||||
{{ isSaving ? 'Creating...' : 'Generate New Key' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keys List -->
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm border-0 rounded-xl mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-0">
|
||||
<h5 class="fw_6 mb-0">Active Terminal Keys</h5>
|
||||
<div class="search-box position-relative" style="min-width: 250px;">
|
||||
<i class="fas fa-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted opacity-50"></i>
|
||||
<input v-model="searchQuery" type="text" class="form-control rounded-pill ps-5" placeholder="Search by name, store or owner...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm border-0 rounded-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4 py-3 border-0">Terminal / Store</th>
|
||||
<th class="py-3 border-0">Access Key</th>
|
||||
<th class="py-3 border-0">Status</th>
|
||||
<th class="py-3 border-0">Expires</th>
|
||||
<th class="py-3 border-0">Last Used</th>
|
||||
<th class="pe-4 py-3 border-0 text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td colspan="6" class="text-center py-5">
|
||||
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
|
||||
Loading keys...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="filteredKeys.length === 0">
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
{{ searchQuery ? 'No search results found.' : 'No POS access keys found.' }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="key in filteredKeys" :key="key.hashkey" :class="{ 'table-row-expired': isKeyExpired(key) }">
|
||||
<td class="ps-4">
|
||||
<div class="fw_6">{{ key.name }}</div>
|
||||
<div class="small text-muted d-flex align-items-center">
|
||||
<i class="fas fa-store me-1 opacity-50"></i> {{ key.store?.name || 'Unknown Store' }}
|
||||
<template v-if="key.store?.owner">
|
||||
<span class="mx-1 opacity-25">|</span>
|
||||
<i class="fas fa-user-circle me-1 opacity-50"></i> {{ key.store.owner.nickname || key.store.owner.name }}
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<code class="bg-light px-2 py-1 rounded small">{{ key.access_key }}</code>
|
||||
<button @click="copyToClipboard(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Copy Key">
|
||||
<i class="far fa-copy"></i>
|
||||
</button>
|
||||
<button @click="copyToClipboard(getPosUrl(key.access_key))" class="btn btn-sm btn-link p-0 text-muted" title="Copy POS URL">
|
||||
<i class="fas fa-link"></i>
|
||||
</button>
|
||||
<button @click="showQrCode(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="View QR Code">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
</button>
|
||||
<button @click="openPosInNewTab(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Open POS Terminal">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</button>
|
||||
<button v-if="canShare" @click="shareKey(key.access_key, key.name)" class="btn btn-sm btn-link p-0 text-muted" title="Share via Protocol">
|
||||
<i class="fas fa-share-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span @click="toggleStatus(key.hashkey)" :class="['badge rounded-pill cursor-pointer', key.status === 'active' ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger']">
|
||||
{{ key.status }}
|
||||
</span>
|
||||
<span v-if="isKeyExpired(key)" class="badge bg-warning text-dark rounded-pill ms-1">expired</span>
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ key.expires_at ? formatDate(key.expires_at) : 'No Expiry' }}
|
||||
</td>
|
||||
<td class="small text-muted">
|
||||
{{ key.last_used_at ? formatDate(key.last_used_at) : 'Never' }}
|
||||
</td>
|
||||
<td class="pe-4 text-end">
|
||||
<button @click="deleteKey(key.hashkey)" class="btn btn-sm btn-soft-danger rounded-circle">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-4 bg-soft-info rounded-xl border border-info border-opacity-25">
|
||||
<h6 class="fw_7"><i class="fas fa-info-circle me-2"></i> How to use:</h6>
|
||||
<p class="small mb-0 text-muted">
|
||||
Copy the <strong>POS URL</strong> and use it on the target POS machine.
|
||||
The machine will be automatically logged into the POS interface for the assigned store using the generated access key.
|
||||
Ensure the status is set to <strong>active</strong> for the key to work.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-xl {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.bg-soft-success {
|
||||
background-color: #e8f5e9;
|
||||
}
|
||||
.bg-soft-danger {
|
||||
background-color: #ffebee;
|
||||
}
|
||||
.bg-soft-info {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
.text-success { color: #2e7d32; }
|
||||
.text-danger { color: #c62828; }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.btn-soft-danger {
|
||||
background: #fff0f0;
|
||||
color: #e74c3c;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn-soft-danger:hover {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
.table-row-expired {
|
||||
opacity: 0.6;
|
||||
background-color: #fff8e1;
|
||||
}
|
||||
.bg-warning {
|
||||
background-color: #ffc107 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,161 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('POS History');
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import PosHistoryList from '../Components/Market/PosHistoryList.vue';
|
||||
import PosTodayStats from '../Components/Market/PosTodayStats.vue';
|
||||
import SkeletonTable from '../Components/Core/Skeleton/SkeletonTable.vue';
|
||||
import { usePosStore } from '../stores/pos';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, required: true },
|
||||
storeName: { type: String, default: 'Store' }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const posStore = usePosStore();
|
||||
const store = ref(null);
|
||||
const loadingStore = ref(false);
|
||||
const loadingStats = ref(false);
|
||||
|
||||
const fetchStoreDetails = async () => {
|
||||
// Only fetch if we don't have a specific name or if we want to ensure latest
|
||||
loadingStore.value = true;
|
||||
try {
|
||||
const response = await axios.post('/View/Store/Details/data', {
|
||||
target: props.target
|
||||
});
|
||||
if (response.data) {
|
||||
store.value = response.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch store details:', e);
|
||||
} finally {
|
||||
loadingStore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTodayStats = async () => {
|
||||
loadingStats.value = true;
|
||||
try {
|
||||
await posStore.fetchTodayStats(props.target);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch today stats:', e);
|
||||
} finally {
|
||||
loadingStats.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStoreDetails();
|
||||
fetchTodayStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pos-history-page pb-5">
|
||||
<div class="header-section shadow-sm mb-4">
|
||||
<div class="tf-container py-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<BackButton
|
||||
:to="{ page: 'ViewStoreMarket', props: { target: props.target } }"
|
||||
text=""
|
||||
className="me-3"
|
||||
/>
|
||||
<div>
|
||||
<h4 class="fw_7 mb-0">POS History</h4>
|
||||
<p class="text-muted small mb-0">
|
||||
{{ store?.name || props.storeName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@click="navigate({ page: 'PosMain', props: { target: props.target } })"
|
||||
class="btn btn-primary rounded-pill shadow-sm px-3 py-2 fw_6"
|
||||
>
|
||||
<i class="fas fa-cash-register me-2"></i> Open POS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container">
|
||||
<PosTodayStats :loading="loadingStats" />
|
||||
|
||||
<div class="glass-card p-3 p-md-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 mt-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="icon-avatar me-3">
|
||||
<i class="fas fa-receipt text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h5 class="fw_6 mb-0">Transaction Records</h5>
|
||||
<span v-if="posStore.posSessionsCount > 0" class="text-muted small">
|
||||
{{ posStore.posSessionsCount }} Sessions Found
|
||||
</span>
|
||||
<span v-else class="text-muted small">
|
||||
Past POS sessions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingStore && !store" class="mt-2">
|
||||
<SkeletonTable :rows="6" :columns="4" />
|
||||
</div>
|
||||
|
||||
<PosHistoryList v-else :storeHash="target" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pos-history-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.header-section {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.icon-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: rgba(var(--primary-rgb), 0.1);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .header-section,
|
||||
:global(.dark-mode) .glass-card {
|
||||
background: #24272c;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .icon-avatar {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .icon-avatar i {
|
||||
color: #10b981 !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,493 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
|
||||
const props = defineProps({ target: String });
|
||||
|
||||
usePageTitle('Join Cooperative');
|
||||
|
||||
const goToLogin = () => { window.location.href = '/login'; };
|
||||
|
||||
const cooperative = ref(null);
|
||||
const prioritySectors = ref([]);
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref('');
|
||||
const fieldErrors = ref({});
|
||||
const step = ref(1);
|
||||
const submitting = ref(false);
|
||||
const userHashkey = ref(null);
|
||||
|
||||
// ── Step 1 ──
|
||||
const accountForm = ref({
|
||||
name: '',
|
||||
username: '',
|
||||
mobile_number: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const mobileError = ref('');
|
||||
const validateMobile = (val) => {
|
||||
if (!val) { mobileError.value = 'Mobile number is required.'; return false; }
|
||||
if (!/^(09|\+639)\d{9}$/.test(val)) {
|
||||
mobileError.value = 'Must be a valid Philippine mobile number (e.g. 09XXXXXXXXX).';
|
||||
return false;
|
||||
}
|
||||
mobileError.value = '';
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── Step 2 ──
|
||||
const membershipTypes = ['REGULAR', 'ASSOCIATE', 'HONORARY', 'LABORATORY'];
|
||||
const membershipLevels = ['PRIMARY', 'SECONDARY', 'TERTIARY'];
|
||||
const commonBonds = ['Residential', 'Institutional', 'Occupational', 'Associational'];
|
||||
const employmentStatuses = ['Employed', 'Underemployed', 'Unemployed', 'Self-employed'];
|
||||
const slpTracks = [{ value: 'MD', label: 'Microenterprise Development (MD)' }, { value: 'EF', label: 'Employment Facilitation (EF)' }];
|
||||
const tupadCategories = ['Underemployed', 'Displaced Worker', 'Senior Citizen (fit to work)', 'PWD', 'Solo Parent', 'Indigenous Person', 'Former Rebel'];
|
||||
const vulnerabilityOptions = ['Indigenous People (IP)', 'Person with Disability (PWD)', 'Senior Citizen', 'Solo Parent', 'Out-of-School Youth (OSY)', 'Internally Displaced Person (IDP)', 'Distressed OFW', 'Former Rebel'];
|
||||
const programOptions = ['SLP', 'TUPAD', 'OSEC/NSRP', '4Ps/Pantawid Pamilya', 'Listahanan'];
|
||||
|
||||
const memberForm = ref({
|
||||
membership_type: 'REGULAR',
|
||||
membership_level: 'PRIMARY',
|
||||
year_beginning: new Date().getFullYear(),
|
||||
officer_position: '',
|
||||
officer_level: '',
|
||||
concurrent_position: '',
|
||||
concurrent_level: '',
|
||||
cooperative_position: '',
|
||||
cooperative_name_alt: '',
|
||||
// Classification
|
||||
priority_sector: [],
|
||||
common_bond: '',
|
||||
vulnerability_classifications: [],
|
||||
// Gov IDs
|
||||
philsys_id: '',
|
||||
sss_number: '',
|
||||
pagibig_number: '',
|
||||
// SLP
|
||||
slp_track: '',
|
||||
slp_association_name: '',
|
||||
listahanan_id: '',
|
||||
fourtps_household_id: '',
|
||||
// TUPAD
|
||||
tupad_category: '',
|
||||
tupad_insurance_beneficiary_name: '',
|
||||
tupad_insurance_beneficiary_relation: '',
|
||||
// OSEC/NSRP
|
||||
preferred_occupation: '',
|
||||
nsrp_skills: [],
|
||||
employment_status: '',
|
||||
// Programs
|
||||
program_participation: [],
|
||||
});
|
||||
|
||||
const newSkill = ref('');
|
||||
const addSkill = () => {
|
||||
const s = newSkill.value.trim();
|
||||
if (s && !memberForm.value.nsrp_skills.includes(s)) {
|
||||
memberForm.value.nsrp_skills.push(s);
|
||||
}
|
||||
newSkill.value = '';
|
||||
};
|
||||
const removeSkill = (i) => memberForm.value.nsrp_skills.splice(i, 1);
|
||||
|
||||
const fetchCooperative = async () => {
|
||||
if (!props.target) {
|
||||
errorMessage.value = 'No cooperative identifier provided.';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [coopRes, settingsRes] = await Promise.all([
|
||||
axios.get(`/api/public/cooperative/${props.target}`),
|
||||
axios.get('/api/public/system-settings'),
|
||||
]);
|
||||
if (coopRes.data.success) cooperative.value = coopRes.data.data;
|
||||
else errorMessage.value = coopRes.data.message || 'Cooperative not found.';
|
||||
|
||||
if (settingsRes.data?.priority_sectors) {
|
||||
prioritySectors.value = settingsRes.data.priority_sectors;
|
||||
}
|
||||
} catch (err) {
|
||||
errorMessage.value = err.response?.status === 404
|
||||
? 'Cooperative not found. Please check the link.'
|
||||
: 'Failed to load cooperative information.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitAccount = async () => {
|
||||
if (submitting.value) return;
|
||||
if (!validateMobile(accountForm.value.mobile_number)) return;
|
||||
fieldErrors.value = {};
|
||||
errorMessage.value = '';
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/public/cooperative/register', {
|
||||
...accountForm.value,
|
||||
cooperative_hash: props.target,
|
||||
});
|
||||
if (res.data.success) {
|
||||
userHashkey.value = res.data.user_hashkey;
|
||||
step.value = 2;
|
||||
} else {
|
||||
errorMessage.value = res.data.message || 'Registration failed.';
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.response?.data?.errors) fieldErrors.value = err.response.data.errors;
|
||||
else errorMessage.value = err.response?.data?.message || 'An error occurred.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitMembership = async () => {
|
||||
if (submitting.value) return;
|
||||
errorMessage.value = '';
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/public/cooperative/complete-membership', {
|
||||
...memberForm.value,
|
||||
user_hashkey: userHashkey.value,
|
||||
cooperative_hash: props.target,
|
||||
});
|
||||
if (res.data.success) step.value = 3;
|
||||
else errorMessage.value = res.data.message || 'Submission failed.';
|
||||
} catch (err) {
|
||||
errorMessage.value = err.response?.data?.message || 'An error occurred.';
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchCooperative);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container py-4" style="max-width:620px;">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!cooperative && errorMessage" class="text-center py-5 animate-fade-in">
|
||||
<i class="fas fa-exclamation-triangle fa-3x text-danger mb-3"></i>
|
||||
<p class="text-danger fw-semibold">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else-if="cooperative">
|
||||
|
||||
<!-- Coop Header -->
|
||||
<div class="text-center mb-4 animate-fade-in">
|
||||
<div class="bg-primary text-white rounded-circle d-inline-flex align-items-center justify-content-center mb-2" style="width:56px;height:56px;">
|
||||
<i class="fas fa-users fa-lg"></i>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-0">{{ cooperative.name }}</h5>
|
||||
<span class="badge bg-primary-subtle text-primary rounded-pill px-3 mt-1 small">
|
||||
{{ cooperative.cooperative_type || 'Cooperative' }}
|
||||
</span>
|
||||
<p v-if="cooperative.address" class="text-muted small mt-1 mb-0">
|
||||
<i class="fas fa-map-marker-alt me-1"></i>{{ cooperative.address }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step Indicator -->
|
||||
<div class="d-flex align-items-center justify-content-center gap-2 mb-4">
|
||||
<template v-for="s in 2" :key="s">
|
||||
<div class="rounded-circle d-inline-flex align-items-center justify-content-center fw-bold"
|
||||
:class="step > s ? 'bg-success text-white' : step === s ? 'bg-primary text-white' : 'bg-light text-muted'"
|
||||
style="width:32px;height:32px;font-size:13px;">
|
||||
<i v-if="step > s" class="fas fa-check" style="font-size:11px;"></i>
|
||||
<span v-else>{{ s }}</span>
|
||||
</div>
|
||||
<span class="small" :class="step >= s ? 'fw-semibold text-dark' : 'text-muted'">
|
||||
{{ s === 1 ? 'Account' : 'Membership' }}
|
||||
</span>
|
||||
<i v-if="s < 2" class="fas fa-chevron-right text-muted small mx-1"></i>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 1: Account ── -->
|
||||
<div v-if="step === 1" class="card border-0 shadow-sm rounded-4 p-4 animate-fade-in">
|
||||
<h6 class="fw-semibold mb-3">Create your account</h6>
|
||||
<div v-if="errorMessage" class="alert alert-danger rounded-3 small py-2">{{ errorMessage }}</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Full Name</label>
|
||||
<input v-model="accountForm.name" type="text" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': fieldErrors.name }" placeholder="Juan Dela Cruz" />
|
||||
<div v-if="fieldErrors.name" class="invalid-feedback">{{ fieldErrors.name[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Username</label>
|
||||
<input v-model="accountForm.username" type="text" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': fieldErrors.username }" placeholder="juandelacruz" autocomplete="username" />
|
||||
<div v-if="fieldErrors.username" class="invalid-feedback">{{ fieldErrors.username[0] }}</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-semibold">Mobile Number</label>
|
||||
<input v-model="accountForm.mobile_number" type="tel"
|
||||
class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': mobileError || fieldErrors.mobile_number }"
|
||||
placeholder="09XXXXXXXXX"
|
||||
@blur="validateMobile(accountForm.mobile_number)" />
|
||||
<div class="invalid-feedback">{{ mobileError || fieldErrors.mobile_number?.[0] }}</div>
|
||||
<div class="form-text small text-muted">Philippine mobile number (09XXXXXXXXX or +639XXXXXXXXX)</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-semibold">Password</label>
|
||||
<input v-model="accountForm.password" type="password" class="form-control rounded-pill"
|
||||
:class="{ 'is-invalid': fieldErrors.password }" placeholder="Min. 6 characters" autocomplete="new-password" />
|
||||
<div v-if="fieldErrors.password" class="invalid-feedback">{{ fieldErrors.password[0] }}</div>
|
||||
</div>
|
||||
|
||||
<button @click="submitAccount" :disabled="submitting" class="btn btn-primary rounded-pill w-100 py-2 fw-semibold">
|
||||
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i v-else class="fas fa-arrow-right me-2"></i>
|
||||
{{ submitting ? 'Creating account...' : 'Continue to Membership' }}
|
||||
</button>
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">Already have an account?
|
||||
<a href="#" @click.prevent="goToLogin" class="text-primary fw-semibold">Log in</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 2: Membership ── -->
|
||||
<div v-else-if="step === 2" class="animate-fade-in">
|
||||
<div class="alert alert-success rounded-3 small py-2 mb-3">
|
||||
<i class="fas fa-check-circle me-2"></i>Account created! Complete your membership application below.
|
||||
</div>
|
||||
<div v-if="errorMessage" class="alert alert-danger rounded-3 small py-2">{{ errorMessage }}</div>
|
||||
|
||||
<!-- Membership Info -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-id-card text-primary me-2"></i>Membership Information</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Type</label>
|
||||
<select v-model="memberForm.membership_type" class="form-select rounded-pill">
|
||||
<option v-for="t in membershipTypes" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Level</label>
|
||||
<select v-model="memberForm.membership_level" class="form-select rounded-pill">
|
||||
<option v-for="l in membershipLevels" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Year Joined</label>
|
||||
<input v-model="memberForm.year_beginning" type="number" class="form-control rounded-pill" :min="1990" :max="new Date().getFullYear()" />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label small fw-semibold">Common Bond</label>
|
||||
<select v-model="memberForm.common_bond" class="form-select rounded-pill">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="b in commonBonds" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12" v-if="prioritySectors.length">
|
||||
<label class="form-label small fw-semibold">Priority Sector <span class="text-muted fw-normal small">(select all that apply)</span></label>
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-6 col-md-4" v-for="s in prioritySectors" :key="s">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'ps-' + s" :value="s" v-model="memberForm.priority_sector">
|
||||
<label class="form-check-label small" :for="'ps-' + s">{{ s }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerability Classifications -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i>Vulnerability Classification <span class="text-muted fw-normal small">(check all that apply)</span></h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6" v-for="opt in vulnerabilityOptions" :key="opt">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'vuln-'+opt"
|
||||
:value="opt" v-model="memberForm.vulnerability_classifications" />
|
||||
<label class="form-check-label small" :for="'vuln-'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Program Participation -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-list-check text-info me-2"></i>Government Program Participation <span class="text-muted fw-normal small">(check all that apply)</span></h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-6" v-for="prog in programOptions" :key="prog">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" :id="'prog-'+prog"
|
||||
:value="prog" v-model="memberForm.program_participation" />
|
||||
<label class="form-check-label small" :for="'prog-'+prog">{{ prog }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gov IDs -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-id-badge text-secondary me-2"></i>Government ID Numbers <span class="text-muted fw-normal small">(Optional)</span></h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">PhilSys ID</label>
|
||||
<input v-model="memberForm.philsys_id" type="text" class="form-control rounded-pill" placeholder="National ID" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">SSS Number</label>
|
||||
<input v-model="memberForm.sss_number" type="text" class="form-control rounded-pill" placeholder="00-0000000-0" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-semibold">Pag-IBIG Number</label>
|
||||
<input v-model="memberForm.pagibig_number" type="text" class="form-control rounded-pill" placeholder="0000-0000-0000" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SLP -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3" v-if="memberForm.program_participation.includes('SLP')">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-seedling text-success me-2"></i>SLP – Sustainable Livelihood Program</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">SLP Track</label>
|
||||
<select v-model="memberForm.slp_track" class="form-select rounded-pill">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="t in slpTracks" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">SLPA / Association Name</label>
|
||||
<input v-model="memberForm.slp_association_name" type="text" class="form-control rounded-pill" placeholder="Association name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Listahanan (NHTO) Household ID</label>
|
||||
<input v-model="memberForm.listahanan_id" type="text" class="form-control rounded-pill" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">4Ps Household ID</label>
|
||||
<input v-model="memberForm.fourtps_household_id" type="text" class="form-control rounded-pill" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TUPAD -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3" v-if="memberForm.program_participation.includes('TUPAD')">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-hard-hat text-warning me-2"></i>TUPAD – Tulong Panghanapbuhay sa Ating Disadvantaged Workers</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Beneficiary Category</label>
|
||||
<select v-model="memberForm.tupad_category" class="form-select rounded-pill">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="c in tupadCategories" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Micro-insurance Beneficiary Name</label>
|
||||
<input v-model="memberForm.tupad_insurance_beneficiary_name" type="text" class="form-control rounded-pill" placeholder="Full name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Relationship to Beneficiary</label>
|
||||
<input v-model="memberForm.tupad_insurance_beneficiary_relation" type="text" class="form-control rounded-pill" placeholder="e.g. Spouse, Child" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OSEC / NSRP -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3" v-if="memberForm.program_participation.includes('OSEC/NSRP')">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-briefcase text-primary me-2"></i>OSEC / NSRP – Employment Profile</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Employment Status</label>
|
||||
<select v-model="memberForm.employment_status" class="form-select rounded-pill">
|
||||
<option value="">— Select —</option>
|
||||
<option v-for="s in employmentStatuses" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">Preferred Occupation</label>
|
||||
<input v-model="memberForm.preferred_occupation" type="text" class="form-control rounded-pill" placeholder="e.g. Farmer, Welder" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Technical Skills</label>
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input v-model="newSkill" type="text" class="form-control rounded-pill"
|
||||
placeholder="Add a skill and press +" @keyup.enter="addSkill" />
|
||||
<button @click="addSkill" class="btn btn-outline-primary rounded-pill px-3">+</button>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
<span v-for="(sk, i) in memberForm.nsrp_skills" :key="i"
|
||||
class="badge bg-primary-subtle text-primary rounded-pill px-3 py-2">
|
||||
{{ sk }} <i class="fas fa-times ms-1 cursor-pointer" @click="removeSkill(i)"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Position Details -->
|
||||
<div class="card border-0 shadow-sm rounded-4 p-4 mb-3">
|
||||
<h6 class="fw-semibold mb-3"><i class="fas fa-user-tie text-primary me-2"></i>Position Details <span class="text-muted fw-normal small">(Optional)</span></h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-8">
|
||||
<label class="form-label small fw-semibold">Officer Position</label>
|
||||
<input v-model="memberForm.officer_position" type="text" class="form-control rounded-pill" placeholder="e.g. Board Member" />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Level</label>
|
||||
<select v-model="memberForm.officer_level" class="form-select rounded-pill">
|
||||
<option value="">—</option>
|
||||
<option v-for="l in membershipLevels" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label small fw-semibold">Concurrent Position</label>
|
||||
<input v-model="memberForm.concurrent_position" type="text" class="form-control rounded-pill" placeholder="e.g. Treasurer" />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<label class="form-label small fw-semibold">Level</label>
|
||||
<select v-model="memberForm.concurrent_level" class="form-select rounded-pill">
|
||||
<option value="">—</option>
|
||||
<option v-for="l in membershipLevels" :key="l" :value="l">{{ l }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Cooperative Position</label>
|
||||
<input v-model="memberForm.cooperative_position" type="text" class="form-control rounded-pill" placeholder="e.g. Chairperson" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-semibold">Alternative Cooperative Name</label>
|
||||
<input v-model="memberForm.cooperative_name_alt" type="text" class="form-control rounded-pill" placeholder="Former or alternate name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="submitMembership" :disabled="submitting" class="btn btn-success rounded-pill w-100 py-2 fw-semibold mb-4">
|
||||
<span v-if="submitting" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i v-else class="fas fa-paper-plane me-2"></i>
|
||||
{{ submitting ? 'Submitting...' : 'Submit Membership Application' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── STEP 3: Done ── -->
|
||||
<div v-else-if="step === 3" class="text-center py-5 animate-fade-in">
|
||||
<i class="fas fa-check-circle fa-4x text-success mb-3"></i>
|
||||
<h4 class="fw-bold">Application Submitted!</h4>
|
||||
<p class="text-muted">You are now registered as a member of <strong>{{ cooperative?.name }}</strong>.<br>You may log in with your credentials.</p>
|
||||
<button @click="goToLogin" class="btn btn-primary rounded-pill px-4 mt-2">
|
||||
<i class="fas fa-sign-in-alt me-2"></i> Go to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,271 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Remove Product From Store Admin');
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useModal } from '../composables/Core/useModal'
|
||||
import { useStoreStore } from '../stores/store'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
const modal = useModal()
|
||||
const storeStore = useStoreStore()
|
||||
|
||||
// Form state
|
||||
const productId = ref(null)
|
||||
const selectedStoreId = ref('')
|
||||
const productData = ref({})
|
||||
|
||||
// Data lists
|
||||
const storeList = ref([])
|
||||
|
||||
// Loading state
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Initialize component
|
||||
onMounted(() => {
|
||||
document.title = 'Remove Product from Store'
|
||||
loadStores()
|
||||
loadProductData()
|
||||
})
|
||||
|
||||
// Load stores
|
||||
const loadStores = async () => {
|
||||
try {
|
||||
const response = await axios.post('/ListStores/List/data')
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
storeList.value = response.data.map(store => ({
|
||||
value: store.id,
|
||||
label: store.name || store.store_name || `Store #${store.id}`
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stores:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Load product data
|
||||
const loadProductData = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Get product ID from route params or query params
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
productId.value = urlParams.get('product_id') || urlParams.get('id')
|
||||
|
||||
if (!productId.value) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Product ID not found. Please select a product to remove.',
|
||||
footer: null
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await axios.post('/View/Product/Details/data', {
|
||||
target: 'product_id',
|
||||
data: { product_id: productId.value }
|
||||
})
|
||||
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
const product = response.data.data
|
||||
productData.value = {
|
||||
name: product.name || '',
|
||||
price: product.price ? String(product.price) : '',
|
||||
unitname: product.unitname || '',
|
||||
barcode: product.barcode || ''
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading product data:', error)
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to load product data.',
|
||||
footer: null
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
if (!selectedStoreId.value) {
|
||||
modal.open({ title: 'Error', body: 'Please select a store', footer: null })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
const showConfirmationModal = () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
const storeName = storeList.value.find(s => s.value == selectedStoreId.value)?.label
|
||||
|
||||
modal.yesNoModal({
|
||||
title: 'Remove Product?',
|
||||
body: `Are you sure you want to remove this product from <strong>${storeName}</strong>?`,
|
||||
onYes: removeProduct,
|
||||
yesText: 'Remove',
|
||||
noText: 'Cancel'
|
||||
})
|
||||
}
|
||||
|
||||
// Remove product from store
|
||||
const removeProduct = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await axios.post('/Products/Admin/RemovefronStore/', {
|
||||
product_id: productId.value,
|
||||
store_id: selectedStoreId.value
|
||||
})
|
||||
|
||||
if (response.data && response.data.success) {
|
||||
modal.continueCancelModal({
|
||||
title: 'Success',
|
||||
body: 'Product removed from store successfully',
|
||||
onContinue: () => {
|
||||
navigate({ page: 'ManageProductAdmin' })
|
||||
},
|
||||
continueText: 'OK',
|
||||
continueClass: 'btn btn-primary',
|
||||
showCancel: false
|
||||
})
|
||||
} else {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: response.data?.message || 'Failed to remove product from store',
|
||||
footer: null
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing product:', error)
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: error.response?.data?.message || 'Failed to remove product from store',
|
||||
footer: null
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
const cancel = () => {
|
||||
navigate({ page: 'UserList' }) // Defaulting to UserList or another valid page
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="remove-product-page pb-5">
|
||||
<br><br>
|
||||
|
||||
<div class="tf-container">
|
||||
<h2 class="fw_6 text-center mb-4">Remove Product from Store</h2>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="text-start mb-3">
|
||||
<button
|
||||
@click="cancel"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<i class="fas fa-arrow-left"></i> Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<!-- Store Selection -->
|
||||
<div class="col-12">
|
||||
<select
|
||||
class="form-select"
|
||||
id="TargetStore"
|
||||
v-model="selectedStoreId"
|
||||
required
|
||||
:disabled="isLoading || storeList.length === 0"
|
||||
>
|
||||
<option value="" disabled>Select Store</option>
|
||||
<option v-for="store in storeList" :key="store.value" :value="store.value">
|
||||
{{ store.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Product Name (Read-only) -->
|
||||
<div class="col-12">
|
||||
<input
|
||||
type="text"
|
||||
id="EditProductName"
|
||||
class="form-control"
|
||||
placeholder="Product Name"
|
||||
:value="productData.name"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Price (Read-only) -->
|
||||
<div class="col-12 col-md-6">
|
||||
<input
|
||||
type="number"
|
||||
id="EditProductPrice"
|
||||
class="form-control"
|
||||
placeholder="Price (PHP)"
|
||||
:value="productData.price"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Unit Name (Read-only) -->
|
||||
<div class="col-12 col-md-6">
|
||||
<input
|
||||
type="text"
|
||||
id="EditProductUnitName"
|
||||
class="form-control"
|
||||
placeholder="Unit (e.g., 25kg)"
|
||||
:value="productData.unitname"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Barcode (Read-only) -->
|
||||
<div class="col-12">
|
||||
<input
|
||||
type="text"
|
||||
id="EditProductBarcode"
|
||||
class="form-control"
|
||||
placeholder="Barcode (12-digit number)"
|
||||
:value="productData.barcode"
|
||||
maxlength="12"
|
||||
pattern="[0-9]*"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="col-12 mt-3">
|
||||
<button
|
||||
id="submit-btn"
|
||||
class="btn btn-danger w-100 py-2"
|
||||
@click="showConfirmationModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Remove Product from Store
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
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';
|
||||
import InputGroupButton from '../Components/Core/Forms/InputGroupButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, required: true }
|
||||
});
|
||||
|
||||
usePageTitle('Shipment Details');
|
||||
const { navigate } = useNavigate();
|
||||
const modal = useModal();
|
||||
|
||||
const shipment = ref(null);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchShipmentDetail = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Shipments/List', { target: props.target });
|
||||
// Simplified: filter from list for now
|
||||
if (response.data.success) {
|
||||
shipment.value = response.data.data.find(s => s.hashkey === props.target);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch shipment detail:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (newStatus) => {
|
||||
try {
|
||||
const response = await axios.post('/Shipments/Status/Update', {
|
||||
target: props.target,
|
||||
status: newStatus
|
||||
});
|
||||
if (response.data.success) {
|
||||
shipment.value.status = newStatus;
|
||||
}
|
||||
} catch (error) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Failed to update status'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchShipmentDetail);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shipment-detail-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<InputGroupButton
|
||||
text="Back to Shipments"
|
||||
variant="text"
|
||||
@click="navigate({ page: 'ShipmentList' })"
|
||||
class="mb-4"
|
||||
>
|
||||
<i class="fas fa-chevron-left me-1"></i> Back to Shipments
|
||||
</InputGroupButton>
|
||||
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!shipment" class="alert alert-danger">
|
||||
Shipment not found.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<CardSimple title="Track Shipment" class="mb-4">
|
||||
<template #headerAction>
|
||||
<span class="badge bg-primary px-3 py-2 rounded-pill">{{ shipment.status }}</span>
|
||||
</template>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="small text-muted">Tracking Number</div>
|
||||
<div class="fw_5 h5">{{ shipment.tracking_number || 'Pending Assignment' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 border-top pt-4">
|
||||
<div class="col-6">
|
||||
<div class="small text-muted mb-1">Customer</div>
|
||||
<div class="fw_5">{{ shipment.customer?.name }}</div>
|
||||
<div class="small">{{ shipment.destination_address }}</div>
|
||||
</div>
|
||||
<div class="col-6 text-end">
|
||||
<div class="small text-muted mb-1">Store</div>
|
||||
<div class="fw_5">{{ shipment.store?.name }}</div>
|
||||
<div class="small">Shipping Fee: ₱{{ shipment.shipping_fee }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSimple>
|
||||
|
||||
<CardSimple title="Update Progress">
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<InputGroupButton
|
||||
v-if="shipment.status === 'PENDING'"
|
||||
text="Mark as Picked Up"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="updateStatus('PICKED_UP')"
|
||||
/>
|
||||
<InputGroupButton
|
||||
v-if="shipment.status === 'PICKED_UP'"
|
||||
text="Mark as In Transit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="updateStatus('IN_TRANSIT')"
|
||||
/>
|
||||
<InputGroupButton
|
||||
v-if="shipment.status === 'IN_TRANSIT'"
|
||||
text="Mark as Delivered"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@click="updateStatus('DELIVERED')"
|
||||
/>
|
||||
<InputGroupButton
|
||||
v-if="['PENDING', 'PICKED_UP', 'IN_TRANSIT'].includes(shipment.status)"
|
||||
text="Mark as Failed"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="updateStatus('FAILED')"
|
||||
/>
|
||||
</div>
|
||||
</CardSimple>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fw_5 { font-weight: 500; }
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import TransactionListSkeleton from '../Components/Core/Skeleton/TransactionListSkeleton.vue';
|
||||
import SearchBar from '../Components/Core/Search/SearchBar.vue';
|
||||
|
||||
usePageTitle('Shipment Tracking');
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const shipments = ref([]);
|
||||
const loading = ref(true);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const fetchShipments = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Shipments/List');
|
||||
if (response.data.success) {
|
||||
shipments.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch shipments:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
switch (status) {
|
||||
case 'DELIVERED': return 'badge bg-success';
|
||||
case 'IN_TRANSIT': return 'badge bg-primary';
|
||||
case 'PENDING': return 'badge bg-warning text-dark';
|
||||
case 'FAILED': return 'badge bg-danger';
|
||||
default: return 'badge bg-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredShipments = computed(() => {
|
||||
if (!searchQuery.value) return shipments.value;
|
||||
const q = searchQuery.value.toLowerCase();
|
||||
return shipments.value.filter(s =>
|
||||
s.tracking_number?.toLowerCase().includes(q) ||
|
||||
s.customer?.name?.toLowerCase().includes(q) ||
|
||||
s.status?.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(fetchShipments);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shipment-list-page pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h3 class="fw_6 mb-0">Shipments</h3>
|
||||
<div class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
||||
{{ filteredShipments.length }} active
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchBar v-model="searchQuery" placeholder="Search by tracking or customer..." class="mb-4" />
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<TransactionListSkeleton :count="8" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredShipments.length === 0" class="text-center py-5 border rounded-20 bg-light">
|
||||
<i class="fas fa-box-open fa-3x text-muted mb-3 opacity-20"></i>
|
||||
<h5>No shipments found</h5>
|
||||
<p class="text-muted">Track your deliveries here</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="list-group list-group-flush rounded-20 overflow-hidden border shadow-sm">
|
||||
<div v-for="shipment in filteredShipments" :key="shipment.hashkey"
|
||||
class="list-group-item list-group-item-action p-4 border-bottom"
|
||||
@click="navigate({ page: 'ShipmentDetail', props: { target: shipment.hashkey } })">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="small text-muted mb-1">#{{ shipment.tracking_number || 'No Tracking' }}</div>
|
||||
<h6 class="mb-1">{{ shipment.customer?.name || 'Unknown Customer' }}</h6>
|
||||
<div class="small text-muted">
|
||||
<i class="fas fa-store me-1"></i> {{ shipment.store?.name || 'Direct Sale' }}
|
||||
</div>
|
||||
</div>
|
||||
<span :class="getStatusBadgeClass(shipment.status)">{{ shipment.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.bg-soft-primary { background-color: rgba(66, 185, 131, 0.1); }
|
||||
.opacity-20 { opacity: 0.2; }
|
||||
</style>
|
||||
@@ -1,143 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '@/composables/Core/usePageTitle';
|
||||
import { useNavigate } from '@/composables/Core/useNavigate';
|
||||
|
||||
usePageTitle('Send Money');
|
||||
const { navigate } = useNavigate();
|
||||
|
||||
const search = ref('');
|
||||
const users = ref([]);
|
||||
const selectedUser = ref(null);
|
||||
const amount = ref('');
|
||||
const isSearching = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (search.value.length < 3) {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
isSearching.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Financial/Credit/SearchUsers', { q: search.value });
|
||||
if (response.data.success) {
|
||||
users.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search failed');
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
let searchTimeout;
|
||||
watch(search, () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(searchUsers, 500);
|
||||
});
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!selectedUser.value || !amount.value) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
if (window.toastr) window.toastr.info('Processing transfer...');
|
||||
const response = await axios.post('/Financial/Credit/Transfer', {
|
||||
recipient_hash: selectedUser.value.hashkey,
|
||||
amount: amount.value
|
||||
});
|
||||
if (response.data.success) {
|
||||
if (window.toastr) window.toastr.success('Transfer successful!');
|
||||
navigate({ page: 'MyWallet' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (window.toastr) window.toastr.error(error.response?.data?.message || 'Transfer failed');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="send-money pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<div class="card border-0 shadow-sm rounded-25 p-4 mb-4">
|
||||
<h5 class="fw_7 mb-4">Send Money</h5>
|
||||
|
||||
<!-- Recipient Search -->
|
||||
<div v-if="!selectedUser" class="search-section">
|
||||
<div class="form-group mb-3">
|
||||
<label class="smallest fw_6 text-muted mb-1">Search Recipient</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-0 rounded-start-pill ps-3">
|
||||
<i :class="isSearching ? 'fas fa-spinner fa-spin' : 'fas fa-search'"></i>
|
||||
</span>
|
||||
<input v-model="search" type="text" class="form-control bg-light border-0 rounded-end-pill pe-3" placeholder="Name or mobile number">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-results">
|
||||
<div v-for="user in users" :key="user.hashkey"
|
||||
@click="selectedUser = user"
|
||||
class="d-flex align-items-center gap-3 p-3 mb-2 bg-light rounded-20 cursor-pointer hover-up">
|
||||
<div class="bg-primary text-white rounded-circle p-2" style="width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fas fa-user"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<h6 class="fw_7 mb-0 text-dark text-truncate">{{ user.fullname || user.name }}</h6>
|
||||
<p class="smallest text-muted mb-0">{{ user.mobile_number }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected User & Amount -->
|
||||
<div v-else class="amount-section animate-fade-in">
|
||||
<div class="d-flex align-items-center gap-3 p-3 mb-4 bg-soft-primary rounded-20 position-relative">
|
||||
<div class="bg-primary text-white rounded-circle p-2" style="width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="fas fa-user fa-lg"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 overflow-hidden">
|
||||
<p class="smallest fw_6 text-muted mb-0">Sending to</p>
|
||||
<h6 class="fw_7 mb-0 text-dark text-truncate">{{ selectedUser.fullname || selectedUser.name }}</h6>
|
||||
</div>
|
||||
<button @click="selectedUser = null" class="btn btn-link py-0 smallest text-danger">Change</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label class="smallest fw_6 text-muted mb-1">Amount to Send</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<span class="input-group-text bg-light border-0 rounded-start-pill ps-4 fw_7">₱</span>
|
||||
<input v-model="amount" type="number" class="form-control bg-light border-0 rounded-end-pill pe-4 fw_8" placeholder="0.00">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="handleTransfer"
|
||||
:disabled="isSubmitting || !amount"
|
||||
class="btn btn-primary rounded-pill w-100 py-3 fw_7 shadow-sm transition-all">
|
||||
<i v-if="isSubmitting" class="fas fa-spinner fa-spin me-2"></i>
|
||||
Confirm Transfer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-25 { border-radius: 25px; }
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.bg-soft-primary { background-color: rgba(var(--primary-rgb), 0.1); }
|
||||
.cursor-pointer { cursor: pointer; }
|
||||
.hover-up:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.05); }
|
||||
.smallest { font-size: 0.75rem; }
|
||||
.transition-all { transition: all 0.2s ease; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
</style>
|
||||
@@ -1,219 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('Transfer My Credit');
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { useModal } from '../composables/Core/useModal'
|
||||
import { useAuth } from '../composables/Core/useAuth'
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
const modal = useModal()
|
||||
const { user, userStore } = useAuth()
|
||||
|
||||
// Form state
|
||||
const transferAmount = ref('')
|
||||
const targetUser = ref('')
|
||||
|
||||
// Data lists
|
||||
const userList = ref([])
|
||||
|
||||
// Loading state
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Initialize component
|
||||
onMounted(() => {
|
||||
document.title = 'Transfer My Credit'
|
||||
|
||||
// Get target user from query params
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
targetUser.value = urlParams.get('target') || ''
|
||||
|
||||
if (!targetUser.value) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Target user not found. Please select a recipient.',
|
||||
footer: null
|
||||
})
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Load users (for target selection)
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const response = await axios.get('/admin/users/list')
|
||||
if (response.data && response.data.success && Array.isArray(response.data.users)) {
|
||||
userList.value = response.data.users.map(user => ({
|
||||
value: user.id,
|
||||
label: `${user.name} (${user.mobile_number}) [${user.username}] ${user.fullname ?? ''}`
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading users:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
if (!transferAmount.value || parseFloat(transferAmount.value) <= 0) {
|
||||
modal.open({ title: 'Error', body: 'Please enter a valid amount', footer: null })
|
||||
return false
|
||||
}
|
||||
|
||||
const currentBalance = userStore.balance || 0
|
||||
if (parseFloat(transferAmount.value) > currentBalance) {
|
||||
modal.open({ title: 'Error', body: 'Insufficient balance', footer: null })
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
const showConfirmationModal = () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
const targetUserName = userList.value.find(u => u.value == targetUser.value)?.label || targetUser.value
|
||||
|
||||
modal.yesNoModal({
|
||||
title: 'Transfer Credit?',
|
||||
body: `You are sending <strong>${(parseFloat(transferAmount.value) || 0).toLocaleString()}</strong> credits from your account to <strong>${targetUserName}</strong>?`,
|
||||
onYes: transferCredit,
|
||||
yesText: 'Continue',
|
||||
noText: 'Cancel'
|
||||
})
|
||||
}
|
||||
|
||||
// Transfer credit
|
||||
const transferCredit = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await axios.post('/user/sendmycredit', {
|
||||
amount: parseFloat(transferAmount.value),
|
||||
target_user: targetUser.value
|
||||
})
|
||||
|
||||
if (response.data === true || (response.data && response.data.success)) {
|
||||
showSuccessModal()
|
||||
} else {
|
||||
showErrorModal(response.data?.message || 'Transfer failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error transferring credit:', error)
|
||||
showErrorModal(error.response?.data?.message || 'Failed to transfer credit. Please try again.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Show success modal
|
||||
const showSuccessModal = () => {
|
||||
modal.continueCancelModal({
|
||||
title: 'Success',
|
||||
body: 'Transfer has been successful.',
|
||||
onContinue: () => {
|
||||
navigate({ page: 'AccountSettings' })
|
||||
},
|
||||
continueText: 'OK',
|
||||
continueClass: 'btn btn-primary',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// Show error modal
|
||||
const showErrorModal = (message) => {
|
||||
modal.open({
|
||||
title: 'Failed',
|
||||
body: message,
|
||||
footer: null
|
||||
})
|
||||
}
|
||||
|
||||
// Cancel and go back
|
||||
const cancel = () => {
|
||||
navigate({ page: 'AccountSettings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="transfer-credit-page pb-5">
|
||||
<br><br>
|
||||
|
||||
<div class="tf-container">
|
||||
<h2 class="fw_6 text-center mb-4">Transfer My Credit</h2>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="text-start mb-4">
|
||||
<BackButton to="AccountSettings" text="Cancel" />
|
||||
</div>
|
||||
|
||||
<!-- Card for Transfer Form -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header ui-sortable-handle" style="cursor: move;">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h4 class="card-title">Transfer Credit</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body" id="credit-amount-request-form">
|
||||
<div class="row g-3">
|
||||
<!-- Amount Input -->
|
||||
<div class="col-md-6">
|
||||
<input
|
||||
type="number"
|
||||
id="transfer-credit-amount-field"
|
||||
class="form-control"
|
||||
placeholder="Amount to Transfer"
|
||||
step="0.01"
|
||||
min="0"
|
||||
v-model.number="transferAmount"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Button -->
|
||||
<div class="col-md-6">
|
||||
<button
|
||||
id="Transfer-credit-initial-button"
|
||||
class="btn btn-primary w-100 py-2"
|
||||
@click="showConfirmationModal"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Transfer Credit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Balance Info -->
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Your Current Balance</h5>
|
||||
<p class="fw_6 fs-4 mb-0" v-if="userStore.balance !== null">
|
||||
{{ (userStore.balance || 0).toLocaleString() }}
|
||||
</p>
|
||||
<p class="text-muted" v-else>Loading balance...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
.card-header {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
import TransactionListSkeleton from '../Components/Core/Skeleton/TransactionListSkeleton.vue';
|
||||
|
||||
usePageTitle('Farmer Verification');
|
||||
const modal = useModal();
|
||||
|
||||
const farmers = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const fetchFarmers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/Farmers/List');
|
||||
if (response.data.success) {
|
||||
farmers.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch farmers');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const verifyFarmer = async (hashkey, status) => {
|
||||
try {
|
||||
const response = await axios.post('/Farmers/Verify', {
|
||||
target: hashkey,
|
||||
status: status
|
||||
});
|
||||
if (response.data.success) {
|
||||
const index = farmers.value.findIndex(f => f.hashkey === hashkey);
|
||||
if (index !== -1) {
|
||||
farmers.value[index].verification_status = status;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
modal.open({
|
||||
title: 'Error',
|
||||
body: 'Verification failed. Please try again.'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchFarmers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="verification-dashboard pb-5">
|
||||
<div class="tf-container mt-4">
|
||||
<BackButton to="Home" />
|
||||
|
||||
<h3 class="fw_6 mb-4">Farmer Verification</h3>
|
||||
|
||||
<div v-if="loading" class="mt-2 text-center">
|
||||
<TransactionListSkeleton :count="5" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="farmers.length > 0" class="list-group list-group-flush rounded-20 overflow-hidden border shadow-sm bg-white">
|
||||
<div v-for="farmer in farmers" :key="farmer.hashkey" class="list-group-item p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="avatar-sm bg-light rounded-circle p-2 me-3 text-center">
|
||||
<i class="fas fa-tractor text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="mb-0 fw_6">{{ farmer.farm_name }}</h6>
|
||||
<div class="small text-muted">Owner: {{ farmer.user?.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="{
|
||||
'badge bg-warning text-dark': farmer.verification_status === 'PENDING',
|
||||
'badge bg-success': farmer.verification_status === 'VERIFIED',
|
||||
'badge bg-danger': farmer.verification_status === 'REJECTED'
|
||||
}">{{ farmer.verification_status }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 small">
|
||||
<i class="fas fa-map-marker-alt me-1"></i> {{ farmer.farm_location }}
|
||||
<div v-if="farmer.main_crops" class="mt-1"><i class="fas fa-leaf me-1 text-success"></i> {{ farmer.main_crops.join(', ') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="farmer.verification_status === 'PENDING'" class="d-flex gap-2">
|
||||
<button @click="verifyFarmer(farmer.hashkey, 'VERIFIED')" class="btn btn-sm btn-success rounded-pill px-3">Approve</button>
|
||||
<button @click="verifyFarmer(farmer.hashkey, 'REJECTED')" class="btn btn-sm btn-outline-danger rounded-pill px-3">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-5">
|
||||
<div class="mb-3">
|
||||
<i class="fad fa-clipboard-list-check fa-4x text-muted opacity-2"></i>
|
||||
</div>
|
||||
<p class="text-muted fw_4">No verification at this time</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rounded-20 { border-radius: 20px; }
|
||||
.avatar-sm { width: 40px; height: 40px; }
|
||||
</style>
|
||||
@@ -1,250 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('View All Photos');
|
||||
|
||||
import { onMounted } from 'vue'
|
||||
import { useNavigate } from '../composables/Core/useNavigate'
|
||||
import { usePhotoList } from '../composables/usePhotoList'
|
||||
import { extractHashkeyFromUrl } from '../composables/useUrlArgument'
|
||||
|
||||
const props = defineProps({
|
||||
photos: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'StoreMarket'
|
||||
}
|
||||
})
|
||||
|
||||
const { navigate } = useNavigate()
|
||||
import BackButton from '../Components/Core/BackButton.vue'
|
||||
const { photos: photoList, loading, fetchPhotos, blobCache } = usePhotoList()
|
||||
|
||||
// Get target from URL if not in props
|
||||
const urlParams = new URL(window.location.href).searchParams
|
||||
const targetHash = props.target || extractHashkeyFromUrl(window.location.pathname) || urlParams.get('target') || urlParams.get('t')
|
||||
const targetType = props.type || urlParams.get('type') || 'StoreMarket'
|
||||
|
||||
onMounted(async () => {
|
||||
document.title = 'Photos'
|
||||
|
||||
if (!targetHash) {
|
||||
navigate({ page: 'ListStores' })
|
||||
return
|
||||
}
|
||||
|
||||
if (props.photos && Array.isArray(props.photos)) {
|
||||
photoList.value = props.photos
|
||||
// Ensure they are cached
|
||||
const { useFileBlobCache } = await import('../composables/useFileBlobCache')
|
||||
const { preCacheFiles } = useFileBlobCache()
|
||||
await preCacheFiles(photoList.value)
|
||||
} else if (targetHash) {
|
||||
await fetchPhotos(targetHash, targetType)
|
||||
}
|
||||
})
|
||||
|
||||
const viewPhoto = (hash) => {
|
||||
navigate({ page: 'PhotoViewer', props: { target: hash } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-all-photos-page pb-5">
|
||||
<div class="tf-container">
|
||||
<div class="pt-4 px-2">
|
||||
<BackButton text="" />
|
||||
</div>
|
||||
<div class="header-section text-center mb-5 mt-2">
|
||||
<h2 class="fw_7 title-gradient mb-3">Photo Gallery</h2>
|
||||
<p class="text-muted">All store photos</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<div class="spinner-grow text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3 text-muted fw_5">Fetching your photos...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="photoList.length === 0" class="text-center py-10 empty-state">
|
||||
<div class="empty-icon-wrapper mb-4">
|
||||
<i class="fas fa-camera-retro fa-4x text-light-gray"></i>
|
||||
</div>
|
||||
<h4 class="fw_6">No Photos Yet</h4>
|
||||
<p class="text-muted">This gallery is currently empty.</p>
|
||||
<button @click="navigate({ page: 'Home' })" class="btn-premium mt-3">
|
||||
Go Back Home
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
<div v-else class="gallery-grid">
|
||||
<div
|
||||
v-for="(photoHash, index) in photoList"
|
||||
:key="photoHash"
|
||||
class="gallery-item-wrapper"
|
||||
>
|
||||
<div class="gallery-card" @click="viewPhoto(photoHash)">
|
||||
<img
|
||||
v-if="blobCache[photoHash]"
|
||||
:src="blobCache[photoHash]"
|
||||
alt="Gallery Photo"
|
||||
class="gallery-image"
|
||||
loading="lazy"
|
||||
>
|
||||
<div v-else class="image-skeleton animate-pulse"></div>
|
||||
|
||||
<div class="gallery-overlay">
|
||||
<div class="overlay-content">
|
||||
<i class="fas fa-expand-alt fa-lg"></i>
|
||||
<span>View Full</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-5 footer-stats" v-if="photoList.length > 0">
|
||||
<span class="badge rounded-pill bg-light text-dark p-2 px-4 shadow-sm">
|
||||
{{ photoList.length }} {{ photoList.length === 1 ? 'Photo' : 'Photos' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.title-gradient {
|
||||
background: linear-gradient(45deg, #2563eb, #7c3aed);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-size: 2.2rem;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.gallery-item-wrapper {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.gallery-card {
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
background: #f1f5f9;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.gallery-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.gallery-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.gallery-card:hover .gallery-image {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.gallery-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.gallery-card:hover .gallery-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transform: translateY(20px);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gallery-card:hover .overlay-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.image-skeleton {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 1.5s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.empty-icon-wrapper {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-premium {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-premium:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
.footer-stats {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,580 +0,0 @@
|
||||
<script setup>
|
||||
import { usePageTitle } from '../composables/Core/usePageTitle';
|
||||
usePageTitle('View Store Market');
|
||||
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useNavigate } from '../composables/Core/useNavigate';
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue';
|
||||
import ProductCard from '../Components/Market/ProductCard.vue';
|
||||
import FileImage from '../Components/Core/FileImage.vue';
|
||||
import { useAuth } from '../composables/Core/useAuth';
|
||||
import { useModal } from '../composables/Core/useModal';
|
||||
import BackButton from '../Components/Core/BackButton.vue';
|
||||
|
||||
const props = defineProps({
|
||||
target: { type: String, required: true }
|
||||
});
|
||||
|
||||
const { navigate } = useNavigate();
|
||||
const { isUltimate, hasRole, UserTypes } = useAuth();
|
||||
const modal = useModal();
|
||||
|
||||
const store = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const showGallery = ref(false);
|
||||
const galleryIndex = ref(0);
|
||||
|
||||
const isBig3 = computed(() => isUltimate.value || hasRole('super operator') || hasRole('operator'));
|
||||
|
||||
const photos = computed(() => store.value?.resolved_photos ?? []);
|
||||
const hasPhotos = computed(() => photos.value.length > 0);
|
||||
|
||||
const fetchStoreDetails = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.post('/View/Store/Details/data', {
|
||||
target: props.target
|
||||
});
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
store.value = response.data.data;
|
||||
} else {
|
||||
error.value = 'Store not found';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch store details:', e);
|
||||
error.value = 'Failed to load store details';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
navigate({ page: 'ListStores' });
|
||||
};
|
||||
|
||||
const viewProduct = (product) => {
|
||||
navigate({
|
||||
page: 'BuyViewProductMarket',
|
||||
props: {
|
||||
payload: {
|
||||
product_hash: product.hashkey,
|
||||
store_hash: props.target
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const viewAllPhotos = () => {
|
||||
if (hasPhotos.value) {
|
||||
galleryIndex.value = 0;
|
||||
showGallery.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const openGallery = (index = 0) => {
|
||||
if (hasPhotos.value) {
|
||||
galleryIndex.value = index;
|
||||
showGallery.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const prevPhoto = () => {
|
||||
if (photos.value.length === 0) return;
|
||||
galleryIndex.value = (galleryIndex.value - 1 + photos.value.length) % photos.value.length;
|
||||
};
|
||||
|
||||
const nextPhoto = () => {
|
||||
if (photos.value.length === 0) return;
|
||||
galleryIndex.value = (galleryIndex.value + 1) % photos.value.length;
|
||||
};
|
||||
|
||||
const editStore = () => {
|
||||
navigate({
|
||||
page: 'EditStoreUltimate',
|
||||
props: { target: props.target }
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToPOS = () => {
|
||||
navigate({
|
||||
page: 'PosMain',
|
||||
props: { target: props.target }
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToPosHistory = () => {
|
||||
navigate({
|
||||
page: 'PosHistory',
|
||||
props: {
|
||||
target: props.target,
|
||||
storeName: store.value?.name
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const reportStore = () => {
|
||||
// Placeholder for report functionality
|
||||
modal.open({
|
||||
title: 'Report Store',
|
||||
body: `Reporting store ${store.value?.name}`
|
||||
});
|
||||
};
|
||||
|
||||
const addProduct = () => {
|
||||
navigate({
|
||||
page: 'AddProductsToStore',
|
||||
props: { target: props.target }
|
||||
});
|
||||
};
|
||||
|
||||
const assignProduct = () => {
|
||||
navigate({
|
||||
page: 'AddProductsToStore',
|
||||
props: { target: props.target }
|
||||
});
|
||||
};
|
||||
|
||||
const toggleDescription = () => {
|
||||
const desc = document.querySelector('.description');
|
||||
if (desc) {
|
||||
desc.classList.toggle('text-truncate-3');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStoreDetails();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="store-details-page pb-5">
|
||||
<div v-if="loading" class="text-center py-5">
|
||||
<LoadingSpinner />
|
||||
<p class="mt-3 text-muted">Loading store...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="tf-container mt-5 text-center">
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
<BackButton to="ListStores" text="Go Back" />
|
||||
</div>
|
||||
|
||||
<template v-else-if="store">
|
||||
<!-- Store Profile Header -->
|
||||
<div class="store-header shadow-sm">
|
||||
<div class="store-banner" @click="hasPhotos ? openGallery(0) : null" :style="{ cursor: hasPhotos ? 'pointer' : 'default' }">
|
||||
<BackButton
|
||||
to="ListStores"
|
||||
className="banner-back-btn"
|
||||
/>
|
||||
<FileImage
|
||||
:src="hasPhotos ? photos[0] : 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin'"
|
||||
class="banner-img"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin"
|
||||
/>
|
||||
</div>
|
||||
<div class="tf-container store-profile">
|
||||
<div class="profile-card glass-card">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="store-avatar shadow" @click="hasPhotos ? openGallery(0) : null" :style="{ cursor: hasPhotos ? 'pointer' : 'default' }">
|
||||
<FileImage
|
||||
:src="hasPhotos ? photos[0] : 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin'"
|
||||
class="avatar-img"
|
||||
fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin"
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-3 pt-2">
|
||||
<h3 class="fw_7 mb-0">{{ store.name }}</h3>
|
||||
<div class="d-flex align-items-center flex-wrap">
|
||||
<div class="text-muted small me-3">
|
||||
<i class="fas fa-map-marker-alt me-1"></i> {{ store.address }}
|
||||
</div>
|
||||
<div v-if="store.category" class="badge bg-soft-secondary px-2 py-1 rounded-pill text-secondary small me-2">
|
||||
{{ store.category }}
|
||||
</div>
|
||||
<div v-if="store.subcategory" class="badge bg-soft-info px-2 py-1 rounded-pill text-info small">
|
||||
{{ store.subcategory }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 text-muted description text-truncate-3" id="storeDescription">
|
||||
{{ store.description }}
|
||||
</div>
|
||||
|
||||
<!-- Store Actions -->
|
||||
<div class="store-actions mt-4 pt-3 border-top">
|
||||
<div class="row g-2 justify-content-center">
|
||||
<div class="col-6 col-sm-3">
|
||||
<button @click="toggleDescription" class="action-btn">
|
||||
<div class="icon-wrapper">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/a72b90763114.bin" alt="Details">
|
||||
</div>
|
||||
<span>Details</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-6 col-sm-3">
|
||||
<button @click="reportStore" class="action-btn">
|
||||
<div class="icon-wrapper">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/5b3227c8ca50.bin" alt="Report">
|
||||
</div>
|
||||
<span>Report</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="hasPhotos" class="col-6 col-sm-3">
|
||||
<button @click="openGallery(0)" class="action-btn photos-btn">
|
||||
<div class="icon-wrapper">
|
||||
<i class="fas fa-images" style="font-size: 22px; color: #8b5cf6;"></i>
|
||||
</div>
|
||||
<span>Photos</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isBig3 || store.can_add_product" class="col-6 col-sm-3">
|
||||
<button @click="addProduct" class="action-btn add-product-btn">
|
||||
<div class="icon-wrapper">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/2596d5468e30.bin" alt="Add Product">
|
||||
</div>
|
||||
<span>Add Product</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="store.can_assign_product" class="col-6 col-sm-3">
|
||||
<button @click="assignProduct" class="action-btn assign-btn">
|
||||
<div class="icon-wrapper">
|
||||
<i class="fas fa-link" style="font-size: 22px; color: #f59e0b;"></i>
|
||||
</div>
|
||||
<span>Assign Product</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isBig3" class="col-6 col-sm-3">
|
||||
<button @click="editStore" class="action-btn manage-btn">
|
||||
<div class="icon-wrapper">
|
||||
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/d3ac5ee9c253.bin" alt="Manage">
|
||||
</div>
|
||||
<span>Manage</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="store.can_access_pos" class="col-6 col-sm-3">
|
||||
<button @click="navigateToPOS" class="action-btn pos-btn">
|
||||
<div class="icon-wrapper">
|
||||
<i class="fas fa-cash-register" style="font-size: 22px; color: #10b981;"></i>
|
||||
</div>
|
||||
<span>POS</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="store.can_access_pos" class="col-6 col-sm-3">
|
||||
<button @click="navigateToPosHistory" class="action-btn history-btn">
|
||||
<div class="icon-wrapper">
|
||||
<i class="fas fa-history" style="font-size: 22px; color: #6366f1;"></i>
|
||||
</div>
|
||||
<span>History</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tf-container mt-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-4 mt-2">
|
||||
<h4 class="fw_7 mb-0">Our Products</h4>
|
||||
<span class="badge bg-soft-primary px-3 py-2 rounded-pill text-primary">
|
||||
{{ store.products?.length || 0 }} Items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.products || store.products.length === 0" class="text-center py-5 bg-light rounded-20">
|
||||
<p class="text-muted mb-0">No products available in this store yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="row g-3">
|
||||
<div v-for="product in store.products" :key="product.hashkey" class="col-6 col-md-4 col-lg-3">
|
||||
<ProductCard :name="product.name" :price="product.store_price || product.price"
|
||||
:unit="product.unitname" :image="product.photourl ? product.photourl[0] : ''"
|
||||
@click="viewProduct(product)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Photo Gallery Modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showGallery" class="photo-gallery-overlay" @click.self="showGallery = false">
|
||||
<button class="gallery-close" @click="showGallery = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<button v-if="photos.length > 1" class="gallery-nav gallery-prev" @click="prevPhoto">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="gallery-img-wrap">
|
||||
<img :src="photos[galleryIndex]" class="gallery-img" :alt="store.name + ' photo ' + (galleryIndex + 1)" />
|
||||
<div class="gallery-counter">{{ galleryIndex + 1 }} / {{ photos.length }}</div>
|
||||
</div>
|
||||
<button v-if="photos.length > 1" class="gallery-nav gallery-next" @click="nextPhoto">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
<div v-if="photos.length > 1" class="gallery-thumbs">
|
||||
<img
|
||||
v-for="(url, i) in photos"
|
||||
:key="i"
|
||||
:src="url"
|
||||
class="gallery-thumb"
|
||||
:class="{ active: i === galleryIndex }"
|
||||
@click="galleryIndex = i"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.store-profile {
|
||||
margin-top: -50px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.store-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
border: 4px solid white;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.description {
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rounded-20 {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.badge.bg-soft-primary {
|
||||
background-color: rgba(66, 185, 131, 0.1);
|
||||
}
|
||||
|
||||
.text-truncate-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-back-btn {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .profile-card {
|
||||
background: #24272c;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .back-btn {
|
||||
background: #24272c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-avatar {
|
||||
border-color: #24272c;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .bg-light {
|
||||
background-color: #1a1c20 !important;
|
||||
}
|
||||
|
||||
.store-actions .action-btn {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.store-actions .action-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.store-actions .icon-wrapper {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--accent-soft);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.store-actions .icon-wrapper img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.store-actions span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.manage-btn .icon-wrapper {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.manage-btn span {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.pos-btn .icon-wrapper {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.pos-btn span {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.history-btn .icon-wrapper {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.history-btn span {
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.assign-btn .icon-wrapper {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.assign-btn span {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.action-btn:active .icon-wrapper {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.bg-soft-secondary {
|
||||
background-color: rgba(100, 116, 139, 0.1);
|
||||
}
|
||||
|
||||
.bg-soft-info {
|
||||
background-color: rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-actions .icon-wrapper {
|
||||
background: #2d333b;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-actions .action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .store-actions span {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global(.dark-mode) .manage-btn span {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.photos-btn .icon-wrapper { background: rgba(139, 92, 246, 0.1); }
|
||||
.photos-btn span { color: #8b5cf6; }
|
||||
|
||||
.photo-gallery-overlay {
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: rgba(0,0,0,0.92);
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.gallery-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: rgba(255,255,255,0.15); border: none;
|
||||
color: #fff; border-radius: 50%; width: 40px; height: 40px;
|
||||
font-size: 18px; display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; z-index: 10000;
|
||||
}
|
||||
.gallery-img-wrap { position: relative; max-width: 90vw; max-height: 70vh; display: flex; align-items: center; justify-content: center; }
|
||||
.gallery-img { max-width: 90vw; max-height: 70vh; object-fit: contain; border-radius: 10px; }
|
||||
.gallery-counter {
|
||||
position: absolute; bottom: -28px; left: 50%; transform: translateX(-50%);
|
||||
color: rgba(255,255,255,0.7); font-size: 0.8rem;
|
||||
}
|
||||
.gallery-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
background: rgba(255,255,255,0.15); border: none; color: #fff;
|
||||
border-radius: 50%; width: 44px; height: 44px; font-size: 18px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer; z-index: 10000;
|
||||
}
|
||||
.gallery-prev { left: 16px; }
|
||||
.gallery-next { right: 16px; }
|
||||
.gallery-thumbs {
|
||||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 6px; max-width: 90vw; overflow-x: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.gallery-thumb {
|
||||
width: 52px; height: 52px; object-fit: cover; border-radius: 8px;
|
||||
opacity: 0.5; cursor: pointer; border: 2px solid transparent;
|
||||
transition: opacity 0.2s, border-color 0.2s; flex-shrink: 0;
|
||||
}
|
||||
.gallery-thumb.active { opacity: 1; border-color: #fff; }
|
||||
</style>
|
||||
@@ -1,53 +1,35 @@
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { UserTypes } from '../../utils/UserTypes.js';
|
||||
import { UserTypes, ADMIN_ROLES, STAFF_ROLES } from '../../utils/UserTypes.js';
|
||||
import { useUserStore } from '../../stores/user.js';
|
||||
|
||||
// Global reactive state to persist throughout the SPA session
|
||||
const globalRole = ref(sessionStorage.getItem('user_acct_type') || UserTypes.PUBLIC);
|
||||
const isFetching = ref(false);
|
||||
|
||||
/**
|
||||
* Fetches the user account type from the server.
|
||||
* Ensures only one request is made per session.
|
||||
*/
|
||||
async function fetchRole() {
|
||||
if (isFetching.value) return;
|
||||
|
||||
// If we already have a specialized role in sessionStorage, don't fetch again
|
||||
const cached = sessionStorage.getItem('user_acct_type');
|
||||
if (cached && cached !== UserTypes.PUBLIC) {
|
||||
return;
|
||||
}
|
||||
if (cached && cached !== UserTypes.PUBLIC) return;
|
||||
|
||||
isFetching.value = true;
|
||||
try {
|
||||
const response = await axios.get('/get/user/acct-type');
|
||||
let acctType = response.data?.acct_type;
|
||||
|
||||
// Handle case where acct_type might be an object (Enum serialization)
|
||||
if (acctType && typeof acctType === 'object' && acctType.value) {
|
||||
acctType = acctType.value;
|
||||
}
|
||||
|
||||
if (acctType && typeof acctType === 'object' && acctType.value) acctType = acctType.value;
|
||||
if (acctType) {
|
||||
globalRole.value = acctType;
|
||||
sessionStorage.setItem('user_acct_type', acctType);
|
||||
}
|
||||
} catch (error) {
|
||||
// If 401, we are likely a guest
|
||||
if (error.response?.status === 401) {
|
||||
globalRole.value = UserTypes.PUBLIC;
|
||||
sessionStorage.setItem('user_acct_type', UserTypes.PUBLIC);
|
||||
} else {
|
||||
console.warn('Failed to fetch user acct type from server:', error);
|
||||
}
|
||||
} finally {
|
||||
isFetching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch attempt if we don't have a definitive role
|
||||
if (!sessionStorage.getItem('user_acct_type') || sessionStorage.getItem('user_acct_type') === UserTypes.PUBLIC) {
|
||||
fetchRole();
|
||||
}
|
||||
@@ -58,90 +40,79 @@ export function resetRole() {
|
||||
sessionStorage.removeItem('user_acct_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing user roles and permissions in the frontend.
|
||||
* @param {Object} [user] - Optional user object for legacy support or specific overrides
|
||||
*/
|
||||
export function useAuth(user = null) {
|
||||
const userStore = useUserStore();
|
||||
|
||||
// Priority: Explicitly passed user prop > user store > sessionStorage fallback
|
||||
|
||||
const currentUser = computed(() => {
|
||||
if (user?.value ?? user) return user?.value ?? user;
|
||||
const storeUser = userStore.user;
|
||||
if (storeUser && Object.keys(storeUser).length > 0) return storeUser;
|
||||
|
||||
try {
|
||||
const stored = sessionStorage.getItem('currentUser');
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const role = computed(() => {
|
||||
// Priority 1: User object passed directly or from store
|
||||
const localRole = userStore.acctType || currentUser.value?.acct_type;
|
||||
if (localRole) {
|
||||
if (typeof localRole === 'object' && localRole.value) return localRole.value;
|
||||
return localRole;
|
||||
}
|
||||
|
||||
// Priority 2: Global fetched role
|
||||
return globalRole.value;
|
||||
});
|
||||
|
||||
const hasRole = (targetRole) => {
|
||||
if (Array.isArray(targetRole)) {
|
||||
return targetRole.some(r => role.value === r);
|
||||
}
|
||||
if (Array.isArray(targetRole)) return targetRole.some(r => role.value === r);
|
||||
return role.value === targetRole;
|
||||
};
|
||||
|
||||
// Role-specific helpers
|
||||
const isUltimate = computed(() => role.value === UserTypes.ULTIMATE);
|
||||
const isSuperOperator = computed(() => role.value === UserTypes.SUPER_OPERATOR);
|
||||
const isOperator = computed(() => role.value === UserTypes.OPERATOR);
|
||||
const isCoordinator = computed(() => role.value === UserTypes.COORDINATOR);
|
||||
const isStoreOwner = computed(() => role.value === UserTypes.STORE_OWNER);
|
||||
const isStoreManager = computed(() => role.value === UserTypes.STORE_MANAGER);
|
||||
const isRider = computed(() => role.value === UserTypes.RIDER);
|
||||
const isSupplier = computed(() => role.value === UserTypes.SUPPLIER);
|
||||
const isSupplierOverseer = computed(() => role.value === UserTypes.SUPPLIER_OVERSEER);
|
||||
const isWholesaleBuyer = computed(() => role.value === UserTypes.WHOLESALE_BUYER);
|
||||
const isAudit = computed(() => role.value === UserTypes.AUDIT);
|
||||
const isUser = computed(() => role.value === UserTypes.USER);
|
||||
const isCoopOfficer = computed(() => role.value === UserTypes.COOP_OFFICER);
|
||||
const isCoopMember = computed(() => role.value === UserTypes.COOP_MEMBER);
|
||||
const isPOSTerminal = computed(() => role.value === UserTypes.POS_TERMINAL);
|
||||
const isPublic = computed(() => role.value === UserTypes.PUBLIC || !userStore.isLoggedIn);
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn);
|
||||
// Barangay-specific role helpers
|
||||
const isSuperAdmin = computed(() => role.value === UserTypes.SUPER_ADMIN);
|
||||
const isPunongBarangay = computed(() => role.value === UserTypes.PUNONG_BARANGAY);
|
||||
const isKagawad = computed(() => role.value === UserTypes.KAGAWAD);
|
||||
const isSecretary = computed(() => role.value === UserTypes.SECRETARY);
|
||||
const isTreasurer = computed(() => role.value === UserTypes.TREASURER);
|
||||
const isSkChairperson = computed(() => role.value === UserTypes.SK_CHAIRPERSON);
|
||||
const isSkCouncilor = computed(() => role.value === UserTypes.SK_COUNCILOR);
|
||||
const isTanod = computed(() => role.value === UserTypes.TANOD);
|
||||
const isBhw = computed(() => role.value === UserTypes.BHW);
|
||||
const isDaycareWorker = computed(() => role.value === UserTypes.DAYCARE_WORKER);
|
||||
const isStaff = computed(() => role.value === UserTypes.STAFF);
|
||||
const isResident = computed(() => role.value === UserTypes.RESIDENT);
|
||||
const isAudit = computed(() => role.value === UserTypes.AUDIT);
|
||||
const isPublic = computed(() => role.value === UserTypes.PUBLIC || !userStore.isLoggedIn);
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn);
|
||||
|
||||
// Group helpers
|
||||
const isAdmin = computed(() => ADMIN_ROLES.includes(role.value));
|
||||
const isBarangayStaff = computed(() => STAFF_ROLES.includes(role.value));
|
||||
|
||||
return {
|
||||
user: currentUser,
|
||||
role,
|
||||
hasRole,
|
||||
isUltimate,
|
||||
isSuperOperator,
|
||||
isOperator,
|
||||
isCoordinator,
|
||||
isStoreOwner,
|
||||
isStoreManager,
|
||||
isRider,
|
||||
isSupplier,
|
||||
isSupplierOverseer,
|
||||
isWholesaleBuyer,
|
||||
isSuperAdmin,
|
||||
isPunongBarangay,
|
||||
isKagawad,
|
||||
isSecretary,
|
||||
isTreasurer,
|
||||
isSkChairperson,
|
||||
isSkCouncilor,
|
||||
isTanod,
|
||||
isBhw,
|
||||
isDaycareWorker,
|
||||
isStaff,
|
||||
isResident,
|
||||
isAudit,
|
||||
isUser,
|
||||
isCoopOfficer,
|
||||
isCoopMember,
|
||||
isPOSTerminal,
|
||||
isPublic,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
isBarangayStaff,
|
||||
UserTypes,
|
||||
refreshRole: fetchRole,
|
||||
// Expose the user store for direct access to user data
|
||||
userStore
|
||||
userStore,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { usePosStore } from '../../stores/pos';
|
||||
import { useUIStore } from '../../stores/ui';
|
||||
import { useOfflineStore } from '../useOfflineStore';
|
||||
import { useNetworkStore } from '../../stores/network';
|
||||
|
||||
export function usePosSession(props) {
|
||||
const posStore = usePosStore();
|
||||
const uiStore = useUIStore();
|
||||
const offlineStore = useOfflineStore();
|
||||
const networkStore = useNetworkStore();
|
||||
const storeHash = ref(null);
|
||||
const showSuccessAnimation = ref(false);
|
||||
const isOfflineMode = ref(false);
|
||||
|
||||
// Helper: Access key from URL or LocalStorage
|
||||
const getStoredAccessKey = () => {
|
||||
try {
|
||||
return localStorage.getItem('pos_access_key');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
// Check for existing session in URL or Storage
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const accessKey = props.access_key || urlParams.get('key') || getStoredAccessKey();
|
||||
const hashkey = props.target;
|
||||
|
||||
if (accessKey) {
|
||||
localStorage.setItem('pos_access_key', accessKey);
|
||||
}
|
||||
|
||||
// 1. Load Session or treat as Store Hash
|
||||
if (hashkey) {
|
||||
// Try loading as session
|
||||
await posStore.loadSession(hashkey, accessKey);
|
||||
|
||||
if (!posStore.activeSession) {
|
||||
// Not a session? Treat it as a direct link to a store terminal
|
||||
storeHash.value = hashkey;
|
||||
posStore.error = null;
|
||||
} else if (posStore.activeSession?.store?.hashkey) {
|
||||
storeHash.value = posStore.activeSession.store.hashkey;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch products (Always synchronized with access key/store hash)
|
||||
await posStore.fetchProducts(accessKey, storeHash.value);
|
||||
|
||||
// 3. Fallback: If no direct session yet, try to load one by access key
|
||||
if (!posStore.activeSession && accessKey) {
|
||||
await posStore.loadSession(null, accessKey);
|
||||
}
|
||||
|
||||
// 4. Synchronization: ensure products are loaded if session was found later
|
||||
if (posStore.activeSession && posStore.products.length === 0) {
|
||||
await posStore.fetchProducts(accessKey, storeHash.value);
|
||||
}
|
||||
|
||||
// 5. Restore offline cart if no server session was found
|
||||
if (!posStore.activeSession) {
|
||||
try {
|
||||
const savedRaw = localStorage.getItem('pos_cart_session')
|
||||
const saved = savedRaw ? JSON.parse(savedRaw) : null
|
||||
if (saved?.offline && saved.cart?.length > 0) {
|
||||
posStore.activeSession = { hashkey: saved.sessionHashkey, offline: true, transactions: [] }
|
||||
posStore.cart = saved.cart
|
||||
posStore.isOfflineMode = true
|
||||
isOfflineMode.value = true
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 6. Load terminal stats
|
||||
await posStore.fetchTodayStats(storeHash.value);
|
||||
};
|
||||
|
||||
const completeTransaction = async (customerName) => {
|
||||
const currentStoreHash = storeHash.value || posStore.activeSession?.store?.hashkey;
|
||||
|
||||
// Try Online First (skip if session is a local offline-only session)
|
||||
let success = false;
|
||||
const isOfflineSession = posStore.activeSession?.offline === true;
|
||||
if (networkStore.isOnline && !isOfflineSession) {
|
||||
success = await posStore.completeTransaction(customerName);
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
// Offline Fallback
|
||||
const txnData = {
|
||||
store_hash: currentStoreHash,
|
||||
customer_name: customerName,
|
||||
items: posStore.cart.map(item => ({
|
||||
product_hashkey: item.product?.hashkey,
|
||||
quantity: item.quantity,
|
||||
price_at_sale: item.price_at_sale
|
||||
})),
|
||||
total: posStore.totalAmount,
|
||||
received: posStore.receivedAmount,
|
||||
change: posStore.changeAmount,
|
||||
method: posStore.paymentMethod
|
||||
};
|
||||
|
||||
const id = await offlineStore.storeTransactionOffline(txnData);
|
||||
if (id) {
|
||||
success = true;
|
||||
isOfflineMode.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
showSuccessAnimation.value = true;
|
||||
|
||||
// Clean up state
|
||||
posStore.resetSession();
|
||||
|
||||
// Delayed re-initialization (gives time for animation)
|
||||
setTimeout(async () => {
|
||||
showSuccessAnimation.value = false;
|
||||
if (networkStore.isOnline) {
|
||||
await posStore.startNewSession(currentStoreHash, '', getStoredAccessKey());
|
||||
await Promise.all([
|
||||
posStore.fetchTodayStats(currentStoreHash),
|
||||
posStore.fetchPosSessions(currentStoreHash, 1)
|
||||
]);
|
||||
isOfflineMode.value = false;
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const startNewSessionSilently = async () => {
|
||||
const accessKey = getStoredAccessKey();
|
||||
if (!storeHash.value && !accessKey) {
|
||||
posStore.error = 'No store selected. Open the POS from a store page or use an access key.';
|
||||
return false;
|
||||
}
|
||||
return await posStore.startNewSession(storeHash.value, '', accessKey);
|
||||
};
|
||||
|
||||
// Keep storeHash in sync with active session if it changes
|
||||
watch(() => posStore.activeSession, (session) => {
|
||||
if (session?.store?.hashkey) {
|
||||
storeHash.value = session.store.hashkey;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
return {
|
||||
storeHash,
|
||||
showSuccessAnimation,
|
||||
initialize,
|
||||
completeTransaction,
|
||||
startNewSessionSilently,
|
||||
getStoredAccessKey,
|
||||
isOfflineMode
|
||||
};
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useGlobalTransactionStore } from '../stores/globalTransaction';
|
||||
|
||||
export function useGlobalTransactions(filters = null) {
|
||||
const store = useGlobalTransactionStore();
|
||||
|
||||
const transactions = computed(() => store.transactions);
|
||||
const isLoading = computed(() => store.isLoading);
|
||||
const error = computed(() => store.error);
|
||||
|
||||
const fetchTransactions = async (newFilters = null) => {
|
||||
return await store.fetchTransactions(newFilters || filters || {});
|
||||
};
|
||||
|
||||
const getProductTransactions = (productHash) => {
|
||||
return computed(() => store.getTransactionsByProduct(productHash));
|
||||
};
|
||||
|
||||
const precache = () => store.precache();
|
||||
|
||||
return {
|
||||
transactions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchTransactions,
|
||||
getProductTransactions,
|
||||
precache
|
||||
};
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useFileBlobCache } from './useFileBlobCache';
|
||||
|
||||
/**
|
||||
* Composable for fetching and managing a list of photos for a specific entity.
|
||||
*/
|
||||
export function usePhotoList() {
|
||||
const { getFile, preCacheFiles, blobCache } = useFileBlobCache();
|
||||
|
||||
const photos = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
/**
|
||||
* Fetch photos for a target hash and type.
|
||||
*
|
||||
* @param {string} targetHash
|
||||
* @param {string} type - 'StoreMarket', 'ProductMarket', 'User'
|
||||
*/
|
||||
const fetchPhotos = async (targetHash, type = 'StoreMarket') => {
|
||||
if (!targetHash) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await axios.post(`/Request/Photos/${type}`, {
|
||||
target: targetHash
|
||||
});
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
photos.value = response.data;
|
||||
// Pre-cache all blobs for these hashes
|
||||
await preCacheFiles(photos.value);
|
||||
} else {
|
||||
photos.value = [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching photos:', err);
|
||||
error.value = 'Failed to load photos.';
|
||||
photos.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
photos,
|
||||
loading,
|
||||
error,
|
||||
fetchPhotos,
|
||||
blobCache,
|
||||
getFile
|
||||
};
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export function useUltimate() {
|
||||
const loading = ref(false);
|
||||
const stats = ref(null);
|
||||
const queryResults = ref(null);
|
||||
const affectedRows = ref(0);
|
||||
const commandOutput = ref('');
|
||||
|
||||
const getStats = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/stats');
|
||||
if (response.data.success) {
|
||||
stats.value = response.data.data;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runQuery = async (query) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/query', { query });
|
||||
if (response.data.success) {
|
||||
queryResults.value = response.data.data || null;
|
||||
affectedRows.value = response.data.affected || 0;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to run query:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMaintenance = async (enabled) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/maintenance/toggle', { enabled });
|
||||
if (response.data.success && stats.value) {
|
||||
stats.value.maintenance_mode = response.data.maintenance_mode;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle maintenance:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const sendGlobalMessage = async (message, type = 'info') => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/global-message', { message, type });
|
||||
} catch (error) {
|
||||
console.error('Failed to send global message:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const flushData = async (target) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/flush', { target });
|
||||
} catch (error) {
|
||||
console.error('Failed to flush data:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const testNotification = async (userHash) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/test-notification', { user_hash: userHash });
|
||||
} catch (error) {
|
||||
console.error('Failed to test notification:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const batchManage = async (action, ids, data = {}) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/batch', { action, ids, data });
|
||||
} catch (error) {
|
||||
console.error('Failed to run batch operation:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const runCommand = async (command) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/command', { command });
|
||||
if (response.data.success) {
|
||||
commandOutput.value = response.data.output;
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to run command:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getLogs = async (type = 'database') => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/logs', { type });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch logs:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackup = () => {
|
||||
// Simple window location change to trigger GET download
|
||||
window.location.href = '/admin/ultimate/backup/download';
|
||||
};
|
||||
|
||||
const getBackups = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/admin/ultimate/backups/list');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch backups:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackupByHash = (hash) => {
|
||||
window.location.href = `/admin/ultimate/backup/download/hash?hash=${hash}`;
|
||||
};
|
||||
|
||||
const renameBackup = async (hash, name) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/backup/rename', { hash, name });
|
||||
} catch (error) {
|
||||
console.error('Failed to rename backup:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBackup = async (hash) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
return await axios.post('/admin/ultimate/backup/delete', { hash });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete backup:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading,
|
||||
stats,
|
||||
queryResults,
|
||||
affectedRows,
|
||||
commandOutput,
|
||||
getStats,
|
||||
runQuery,
|
||||
toggleMaintenance,
|
||||
sendGlobalMessage,
|
||||
flushData,
|
||||
testNotification,
|
||||
batchManage,
|
||||
runCommand,
|
||||
getLogs,
|
||||
downloadBackup,
|
||||
getBackups,
|
||||
downloadBackupByHash,
|
||||
renameBackup,
|
||||
deleteBackup
|
||||
};
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
export function useUserAdditionalDetails() {
|
||||
const details = ref({
|
||||
settings: {},
|
||||
details: {}
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const joinedCooperatives = ref([]);
|
||||
|
||||
const fetchUserDetails = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/UserAdditionalDetails/Get');
|
||||
if (response.data.success) {
|
||||
details.value = response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user additional details:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const joinCooperative = async (cooperativeHash) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/UserAdditionalDetails/UpdateCooperatives', {
|
||||
cooperative_hash: cooperativeHash,
|
||||
action: 'add'
|
||||
});
|
||||
if (response.data.success) {
|
||||
// If it was successful, update the local settings to reflect it
|
||||
if (!details.value.settings.cooperatives) {
|
||||
details.value.settings.cooperatives = [];
|
||||
}
|
||||
if (!details.value.settings.cooperatives.includes(cooperativeHash)) {
|
||||
details.value.settings.cooperatives.push(cooperativeHash);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to join cooperative:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const leaveCooperative = async (cooperativeHash) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/UserAdditionalDetails/UpdateCooperatives', {
|
||||
cooperative_hash: cooperativeHash,
|
||||
action: 'remove'
|
||||
});
|
||||
if (response.data.success) {
|
||||
if (details.value.settings.cooperatives) {
|
||||
details.value.settings.cooperatives = details.value.settings.cooperatives.filter(h => h !== cooperativeHash);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to leave cooperative:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchJoinedCooperatives = async (userHash = null) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/UserAdditionalDetails/GetCooperatives', {
|
||||
user_hash: userHash
|
||||
});
|
||||
if (response.data.success) {
|
||||
joinedCooperatives.value = response.data.data;
|
||||
return joinedCooperatives.value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user cooperatives:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const searchUsersByCooperative = async (cooperativeHash) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.post('/UserAdditionalDetails/SearchByCooperative', {
|
||||
cooperative_hash: cooperativeHash
|
||||
});
|
||||
if (response.data.success) {
|
||||
return response.data.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to search users by cooperative:', error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const getJoinedCooperativeHashes = () => {
|
||||
return details.value.settings.cooperatives || [];
|
||||
};
|
||||
|
||||
const hasJoinedCooperative = (cooperativeHash) => {
|
||||
return getJoinedCooperativeHashes().includes(cooperativeHash);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchUserDetails();
|
||||
});
|
||||
|
||||
return {
|
||||
details,
|
||||
isLoading,
|
||||
joinedCooperatives,
|
||||
fetchUserDetails,
|
||||
joinCooperative,
|
||||
leaveCooperative,
|
||||
fetchJoinedCooperatives,
|
||||
searchUsersByCooperative,
|
||||
getJoinedCooperativeHashes,
|
||||
hasJoinedCooperative
|
||||
};
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import axios from 'axios';
|
||||
|
||||
export const useGlobalTransactionStore = defineStore('globalTransaction', {
|
||||
state: () => ({
|
||||
transactions: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
getTransactionsByProduct: (state) => (productHash) => {
|
||||
return state.transactions.filter(tx => tx.product_hash === productHash);
|
||||
},
|
||||
getTransactionsByStore: (state) => (storeHash) => {
|
||||
return state.transactions.filter(tx => tx.store_hash === storeHash);
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchTransactions(filters = {}) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
const response = await axios.post('/admin/transactions/list', filters);
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
this.transactions = response.data;
|
||||
this.lastFetched = Date.now();
|
||||
}
|
||||
|
||||
return this.transactions;
|
||||
} catch (err) {
|
||||
this.error = 'Failed to load transactions';
|
||||
console.error('Error fetching transactions:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async precache() {
|
||||
// Avoid refetching if data is fresh (less than 5 minutes old)
|
||||
if (this.transactions.length > 0 && this.lastFetched && (Date.now() - this.lastFetched < 300000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fetchTransactions();
|
||||
},
|
||||
|
||||
clearCache() {
|
||||
this.transactions = [];
|
||||
this.lastFetched = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,439 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import { db } from '../db'
|
||||
import { useNetworkStore } from './network'
|
||||
|
||||
export const usePosStore = defineStore('pos', {
|
||||
state: () => ({
|
||||
activeSession: null,
|
||||
cart: [],
|
||||
products: [],
|
||||
categories: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
receivedAmount: 0,
|
||||
paymentMethod: 'cash',
|
||||
paymentField: '',
|
||||
todayStats: { count: 0, total: 0, store_name: null, store_photo: null },
|
||||
customerSuggestions: [],
|
||||
cachedCustomers: JSON.parse(localStorage.getItem('pos_cached_customers') || '[]'),
|
||||
posSessions: [],
|
||||
posSessionsCount: 0,
|
||||
posSessionsPage: 1,
|
||||
isOfflineMode: false,
|
||||
lastSync: localStorage.getItem('pos_last_sync') || null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
totalAmount: (state) => {
|
||||
return state.cart.reduce((sum, item) => sum + (item.total_price || 0), 0)
|
||||
},
|
||||
changeAmount: (state) => {
|
||||
return Math.max(0, state.receivedAmount - state.totalAmount)
|
||||
},
|
||||
itemsCount: (state) => {
|
||||
return state.cart.reduce((count, item) => count + (item.quantity || 0), 0)
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchProducts(accessKey = null, storeHash = null) {
|
||||
this.loading = true
|
||||
try {
|
||||
// Try Local DB first if offline
|
||||
if (this.isOfflineMode) {
|
||||
const localProducts = await db.products.toArray()
|
||||
if (localProducts.length > 0) {
|
||||
this.products = localProducts
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const params = {}
|
||||
if (accessKey) params.access_key = accessKey
|
||||
if (storeHash) params.store_hash = storeHash
|
||||
if (this.activeSession) params.session_hash = this.activeSession.hashkey
|
||||
|
||||
const response = await axios.post('/Market/Products/List', params)
|
||||
this.products = response.data || []
|
||||
|
||||
// Save to local DB for next time
|
||||
if (this.products.length > 0) {
|
||||
await db.products.clear()
|
||||
await db.products.bulkPut(this.products)
|
||||
this.lastSync = new Date().toISOString()
|
||||
localStorage.setItem('pos_last_sync', this.lastSync)
|
||||
}
|
||||
|
||||
// Extract unique categories
|
||||
const cats = new Set(this.products.map(p => p.category).filter(Boolean))
|
||||
this.categories = Array.from(cats)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error)
|
||||
// Fallback to local DB on network error
|
||||
const localProducts = await db.products.toArray()
|
||||
if (localProducts.length > 0) {
|
||||
this.products = localProducts
|
||||
} else if (this.products.length === 0) {
|
||||
// Only surface the error if nothing is already showing
|
||||
this.error = 'Failed to load products'
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async startNewSession(storeHash, customerName = '', accessKey = null) {
|
||||
if (this.loading) return
|
||||
this.loading = true
|
||||
try {
|
||||
const networkStore = useNetworkStore()
|
||||
|
||||
const payload = {
|
||||
customer_name: customerName
|
||||
}
|
||||
|
||||
if (!networkStore.isOnline) {
|
||||
const savedRaw = localStorage.getItem('pos_cart_session')
|
||||
const saved = savedRaw ? JSON.parse(savedRaw) : null
|
||||
if (saved?.offline && saved.cart?.length > 0) {
|
||||
this.activeSession = { hashkey: saved.sessionHashkey, offline: true, transactions: [] }
|
||||
this.cart = saved.cart
|
||||
} else {
|
||||
this.activeSession = {
|
||||
hashkey: 'offline-' + Date.now(),
|
||||
customer_name: customerName,
|
||||
offline: true,
|
||||
transactions: []
|
||||
}
|
||||
this.cart = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (accessKey) payload.access_key = accessKey
|
||||
if (storeHash) payload.store_hash = storeHash
|
||||
|
||||
const response = await axios.post('/api/pos/start', payload)
|
||||
if (response.data && response.data.success) {
|
||||
await this.loadSession(response.data.data.hashkey)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to start session:', error)
|
||||
const serverMessage = error?.response?.data?.message
|
||||
this.error = serverMessage || 'Failed to start session'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async loadSession(hashkey = null, accessKey = null) {
|
||||
this.loading = true
|
||||
try {
|
||||
const params = {}
|
||||
if (hashkey) params.target = hashkey
|
||||
if (accessKey) params.access_key = accessKey
|
||||
|
||||
const response = await axios.post('/api/pos/session', params)
|
||||
if (response.data && response.data.success) {
|
||||
this.activeSession = response.data.data
|
||||
this.cart = this.activeSession.transactions || []
|
||||
if (this.cart.length === 0) {
|
||||
this._restoreCartFromStorage(this.activeSession.hashkey)
|
||||
}
|
||||
} else {
|
||||
this.error = 'Session not found or invalid'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session:', error)
|
||||
this.error = 'Failed to load session'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async addToCart(productHash, quantity = 1, customPrice = null) {
|
||||
if (!this.activeSession && !this.isOfflineMode) return
|
||||
|
||||
// Offline Fallback
|
||||
if (this.isOfflineMode || !navigator.onLine) {
|
||||
const product = this.products.find(p => p.hashkey === productHash);
|
||||
if (!product) return;
|
||||
|
||||
const price = customPrice !== null ? customPrice : (product.price || 0);
|
||||
const existingIndex = this.cart.findIndex(item => item.product?.hashkey === productHash);
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (quantity <= 0) {
|
||||
this.cart.splice(existingIndex, 1);
|
||||
} else {
|
||||
const item = this.cart[existingIndex];
|
||||
item.quantity = quantity;
|
||||
item.price_at_sale = price;
|
||||
item.total_price = item.quantity * item.price_at_sale;
|
||||
}
|
||||
} else if (quantity > 0) {
|
||||
this.cart.push({
|
||||
id: 'local-' + Date.now(),
|
||||
product: product,
|
||||
quantity: quantity,
|
||||
price_at_sale: price,
|
||||
total_price: price * quantity
|
||||
});
|
||||
}
|
||||
this._saveCartToStorage()
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
session_hash: this.activeSession.hashkey,
|
||||
product_hash: productHash,
|
||||
quantity: quantity
|
||||
}
|
||||
if (customPrice !== null) payload.price = customPrice
|
||||
|
||||
const response = await axios.post('/api/pos/add-item', payload)
|
||||
if (response.data && response.data.success) {
|
||||
// Update session state directly from the response
|
||||
this.activeSession = response.data.data
|
||||
this.cart = this.activeSession.transactions || []
|
||||
this._saveCartToStorage()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add item:', error)
|
||||
this.error = 'Failed to add item to cart'
|
||||
}
|
||||
},
|
||||
|
||||
async removeFromCart(transactionId) {
|
||||
if (!this.activeSession) return
|
||||
|
||||
if (this.isOfflineMode || !navigator.onLine) {
|
||||
this.cart = this.cart.filter(item => item.id !== transactionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/pos/remove-item', {
|
||||
session_hash: this.activeSession.hashkey,
|
||||
transaction_id: transactionId
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
// Update session state directly from the response
|
||||
this.activeSession = response.data.data
|
||||
this.cart = this.activeSession.transactions || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to remove item:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async completeTransaction(customerName = '') {
|
||||
if (!this.activeSession || this.loading) return
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await axios.post('/api/pos/complete', {
|
||||
session_hash: this.activeSession.hashkey,
|
||||
received_amount: this.receivedAmount,
|
||||
payment_method: this.paymentMethod,
|
||||
payment_field: this.paymentField,
|
||||
customer_name: customerName
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
this.activeSession = response.data.data
|
||||
this._clearCartStorage()
|
||||
|
||||
// Add customer to cache if provided
|
||||
if (customerName) {
|
||||
this.updateCustomerCache([{
|
||||
name: customerName,
|
||||
hashkey: 'new-' + Date.now() // Temporary hash until next fetch
|
||||
}]);
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to complete transaction:', error)
|
||||
this.error = 'Payment failed'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
_saveCartToStorage() {
|
||||
if (!this.activeSession) return
|
||||
try {
|
||||
localStorage.setItem('pos_cart_session', JSON.stringify({
|
||||
sessionHashkey: this.activeSession.hashkey,
|
||||
offline: this.activeSession.offline || false,
|
||||
cart: this.cart
|
||||
}))
|
||||
} catch {}
|
||||
},
|
||||
|
||||
_restoreCartFromStorage(sessionHashkey) {
|
||||
try {
|
||||
const raw = localStorage.getItem('pos_cart_session')
|
||||
if (!raw) return false
|
||||
const saved = JSON.parse(raw)
|
||||
if (saved.sessionHashkey === sessionHashkey && saved.cart?.length > 0) {
|
||||
this.cart = saved.cart
|
||||
return true
|
||||
}
|
||||
} catch {}
|
||||
return false
|
||||
},
|
||||
|
||||
_clearCartStorage() {
|
||||
localStorage.removeItem('pos_cart_session')
|
||||
},
|
||||
|
||||
resetSession() {
|
||||
this.activeSession = null
|
||||
this.cart = []
|
||||
this.receivedAmount = 0
|
||||
this.paymentField = ''
|
||||
this._clearCartStorage()
|
||||
// Keep the access key as it defines the terminal session
|
||||
},
|
||||
|
||||
async fetchTodayStats(storeHash = null) {
|
||||
try {
|
||||
const response = await axios.post('/api/pos/stats', {
|
||||
store_hash: storeHash
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
this.todayStats = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch today stats:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchCustomerSuggestions(query = '', storeHash = null) {
|
||||
// 1. Show cached results immediately if available
|
||||
if (this.cachedCustomers.length > 0) {
|
||||
const q = query.toLowerCase();
|
||||
this.customerSuggestions = query
|
||||
? this.cachedCustomers.filter(c => c.name.toLowerCase().includes(q)).slice(0, 10)
|
||||
: this.cachedCustomers.slice(-3).reverse(); // Show last 3 most recent if no query
|
||||
}
|
||||
|
||||
// 2. Background fetch to get current/complete list
|
||||
try {
|
||||
const response = await axios.post('/api/pos/get-customers', {
|
||||
query: query,
|
||||
store_hash: storeHash
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
const newSuggestions = response.data.data;
|
||||
this.customerSuggestions = newSuggestions;
|
||||
|
||||
// Update persistent cache
|
||||
this.updateCustomerCache(newSuggestions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch customer suggestions:', error)
|
||||
}
|
||||
},
|
||||
|
||||
updateCustomerCache(newCustomers) {
|
||||
const merged = [...this.cachedCustomers];
|
||||
newCustomers.forEach(newCust => {
|
||||
const index = merged.findIndex(c => c.name.toLowerCase() === newCust.name.toLowerCase());
|
||||
if (index === -1) {
|
||||
merged.push(newCust);
|
||||
} else {
|
||||
// Update existing if new one has real hashkey
|
||||
if (!merged[index].hashkey.startsWith('new-') || newCust.hashkey) {
|
||||
merged[index] = newCust;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Limit cache to 200 most recent
|
||||
this.cachedCustomers = merged.slice(-200);
|
||||
localStorage.setItem('pos_cached_customers', JSON.stringify(this.cachedCustomers));
|
||||
},
|
||||
|
||||
async fetchPosSessions(storeHash, page = 1) {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await axios.post('/api/pos/sessions/list', {
|
||||
store_hash: storeHash,
|
||||
page: page,
|
||||
per_page: 10
|
||||
})
|
||||
if (response.data && response.data.success) {
|
||||
const { sessions, total_count, page: currentPage } = response.data.data
|
||||
if (page === 1) {
|
||||
this.posSessions = sessions
|
||||
} else {
|
||||
// Avoid duplicates
|
||||
const existingHashes = new Set(this.posSessions.map(s => s.hashkey))
|
||||
const newSessions = sessions.filter(s => !existingHashes.has(s.hashkey))
|
||||
this.posSessions = [...this.posSessions, ...newSessions]
|
||||
}
|
||||
this.posSessionsCount = total_count
|
||||
this.posSessionsPage = currentPage
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch POS sessions:', error)
|
||||
this.error = 'Failed to load POS history'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
syncFromSSE(data) {
|
||||
// 1. Sync Today's Stats
|
||||
if (data.pos_stats) {
|
||||
// Merge into existing stats to preserve store_name/photo if SSE payload is partial
|
||||
this.todayStats = {
|
||||
...this.todayStats,
|
||||
...data.pos_stats
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Sync Customers (Merge into search suggestions and persistent cache)
|
||||
if (data.customers && data.customers.length > 0) {
|
||||
this.updateCustomerCache(data.customers);
|
||||
|
||||
// If currently showing recent customers, refresh the view
|
||||
if (!this.loading && this.customerSuggestions.length > 0) {
|
||||
// Minor delay to avoid flickering if user is typing
|
||||
const recent = this.cachedCustomers.slice(-3).reverse();
|
||||
this.customerSuggestions = recent;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync Inventory Deltas (Stock/Price changes + newly assigned products)
|
||||
if (data.inventory_deltas && data.inventory_deltas.length > 0) {
|
||||
let hasNewProduct = false;
|
||||
data.inventory_deltas.forEach(delta => {
|
||||
const index = this.products.findIndex(p => p.hashkey === delta.hashkey || p.id === delta.id);
|
||||
if (index !== -1) {
|
||||
this.products[index] = {
|
||||
...this.products[index],
|
||||
...delta
|
||||
};
|
||||
} else if (this.products.length > 0) {
|
||||
// Product not in list — newly assigned to this store
|
||||
hasNewProduct = true;
|
||||
}
|
||||
});
|
||||
// Trigger a full reload so store-specific price/available are fetched correctly.
|
||||
// Requires an active session so the backend resolves the correct store.
|
||||
if (hasNewProduct && this.activeSession) {
|
||||
this.fetchProducts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import { useOPFS } from '../composables/useOPFS'
|
||||
|
||||
const opfs = useOPFS()
|
||||
const CACHE_KEY_PRODUCTS = 'cached_products.json'
|
||||
|
||||
export const useProductStore = defineStore('product', {
|
||||
state: () => ({
|
||||
products: [],
|
||||
currentProduct: null,
|
||||
categories: [],
|
||||
subcategories: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
detailsCache: {} // Cache for full product details keyed by hashkey
|
||||
}),
|
||||
actions: {
|
||||
async fetchProducts(force = false) {
|
||||
if (!force && this.products.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
// Attempt to load from cache first for immediate UI update
|
||||
try {
|
||||
const cachedProducts = await opfs.loadJSON(CACHE_KEY_PRODUCTS)
|
||||
if (cachedProducts && Array.isArray(cachedProducts)) {
|
||||
this.products = cachedProducts
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load products from cache:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post('/Market/Products/List')
|
||||
const newProducts = response.data || []
|
||||
|
||||
// Merge or replace
|
||||
this.products = newProducts
|
||||
|
||||
// Save to cache
|
||||
await opfs.saveJSON(CACHE_KEY_PRODUCTS, this.products)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error)
|
||||
this.error = error.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
syncFromSSE(data) {
|
||||
// 1. Full Market List
|
||||
if (data.products_market && data.products_market.length > 0) {
|
||||
console.log('[ProductStore] Syncing full market list from SSE');
|
||||
this.products = data.products_market;
|
||||
// Optionally cache to OPFS
|
||||
opfs.saveJSON(CACHE_KEY_PRODUCTS, this.products).catch(() => {});
|
||||
}
|
||||
|
||||
// 2. Deltas (Inventory/Price/Details)
|
||||
if (data.inventory_deltas && data.inventory_deltas.length > 0) {
|
||||
data.inventory_deltas.forEach(delta => {
|
||||
const index = this.products.findIndex(p => p.hashkey === delta.hashkey);
|
||||
if (index !== -1) {
|
||||
this.products[index] = {
|
||||
...this.products[index],
|
||||
...delta
|
||||
};
|
||||
} else {
|
||||
// New product added
|
||||
this.products.unshift(delta);
|
||||
}
|
||||
|
||||
// Also update currentProduct if it matches
|
||||
if (this.currentProduct && (this.currentProduct.hashkey === delta.hashkey)) {
|
||||
this.currentProduct = {
|
||||
...this.currentProduct,
|
||||
...delta
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. New detailed product data from SSE (if added)
|
||||
if (data.product_details && typeof data.product_details === 'object') {
|
||||
Object.entries(data.product_details).forEach(([hash, details]) => {
|
||||
this.detailsCache[hash] = details;
|
||||
|
||||
if (this.currentProduct && this.currentProduct.hashkey === hash) {
|
||||
this.currentProduct = { ...this.currentProduct, ...details };
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async fetchProductById(hashkey, storeHash = null) {
|
||||
if (this.detailsCache[hashkey]) {
|
||||
this.currentProduct = this.detailsCache[hashkey];
|
||||
// return early but still fetch in background if needed (optional)
|
||||
// for now just return it
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.post('/View/Product/Details/data', {
|
||||
target: hashkey,
|
||||
data: { store_hash: storeHash }
|
||||
})
|
||||
if (response.data && response.data.success && response.data.data) {
|
||||
this.currentProduct = response.data.data
|
||||
this.detailsCache[hashkey] = this.currentProduct
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch product:', error)
|
||||
this.error = error.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchCategories() {
|
||||
try {
|
||||
const response = await axios.get('/api/categories')
|
||||
this.categories = response.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchSubcategories(categoryId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/subcategories?category_id=${categoryId}`)
|
||||
this.subcategories = response.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subcategories:', error)
|
||||
}
|
||||
},
|
||||
|
||||
async createProduct(data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.post('/api/products', data)
|
||||
if (response.data && response.data.success) {
|
||||
this.products.push(response.data.product)
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to create product:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateProduct(id, data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.put(`/api/products/${id}`, data)
|
||||
if (response.data && response.data.success) {
|
||||
const index = this.products.findIndex(p => p.id === id)
|
||||
if (index !== -1) {
|
||||
this.products[index] = response.data.product
|
||||
}
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to update product:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteProduct(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.delete(`/api/products/${id}`)
|
||||
if (response.data && response.data.success) {
|
||||
this.products = this.products.filter(p => p.id !== id)
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to delete product:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateProductStatus(productId, status) {
|
||||
try {
|
||||
const response = await axios.post('/api/products/status', {
|
||||
product_id: productId,
|
||||
status: status
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to update product status:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
resetCurrentProduct() {
|
||||
this.currentProduct = null
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,160 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import { useOPFS } from '../composables/useOPFS'
|
||||
|
||||
const opfs = useOPFS()
|
||||
const CACHE_KEY_STORES = 'cached_stores.json'
|
||||
|
||||
export const useStoreStore = defineStore('store', {
|
||||
state: () => ({
|
||||
stores: [],
|
||||
currentStore: null,
|
||||
storeProducts: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}),
|
||||
actions: {
|
||||
async fetchStores() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
// Load from cache first
|
||||
try {
|
||||
const cachedStores = await opfs.loadJSON(CACHE_KEY_STORES)
|
||||
if (cachedStores && Array.isArray(cachedStores)) {
|
||||
this.stores = cachedStores
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load stores from cache:', e)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/stores')
|
||||
this.stores = response.data || []
|
||||
|
||||
// Save to cache
|
||||
await opfs.saveJSON(CACHE_KEY_STORES, this.stores)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stores:', error)
|
||||
this.error = error.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchStoreByHash(hash) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.get(`/api/stores/${hash}`)
|
||||
if (response.data && response.data.success) {
|
||||
this.currentStore = response.data.store
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch store:', error)
|
||||
this.error = error.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async createStore(data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.post('/api/stores', data)
|
||||
if (response.data && response.data.success) {
|
||||
this.stores.push(response.data.store)
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to create store:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateStore(id, data) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.put(`/api/stores/${id}`, data)
|
||||
if (response.data && response.data.success) {
|
||||
const index = this.stores.findIndex(s => s.id === id)
|
||||
if (index !== -1) {
|
||||
this.stores[index] = response.data.store
|
||||
}
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to update store:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteStore(id) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
try {
|
||||
const response = await axios.delete(`/api/stores/${id}`)
|
||||
if (response.data && response.data.success) {
|
||||
this.stores = this.stores.filter(s => s.id !== id)
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to delete store:', error)
|
||||
this.error = error.message
|
||||
throw error
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async fetchStoreProducts(storeId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/stores/${storeId}/products`)
|
||||
this.storeProducts = response.data || []
|
||||
return this.storeProducts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch store products:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async addProductToStore(storeId, productId, quantity, price) {
|
||||
try {
|
||||
const response = await axios.post(`/api/stores/${storeId}/products`, {
|
||||
product_id: productId,
|
||||
quantity: quantity,
|
||||
price: price
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to add product to store:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async removeProductFromStore(storeId, productId) {
|
||||
try {
|
||||
const response = await axios.delete(`/api/stores/${storeId}/products/${productId}`)
|
||||
if (response.data && response.data.success) {
|
||||
this.storeProducts = this.storeProducts.filter(p => p.product_id !== productId)
|
||||
}
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Failed to remove product from store:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
resetCurrentStore() {
|
||||
this.currentStore = null
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,19 +1,52 @@
|
||||
export const UserTypes = {
|
||||
ULTIMATE: 'ult',
|
||||
SUPER_OPERATOR: 'super operator',
|
||||
OPERATOR: 'operator',
|
||||
COORDINATOR: 'coordinator',
|
||||
COOP_OFFICER: 'coop officer',
|
||||
COOP_MEMBER: 'coop member',
|
||||
SUPPLIER_OVERSEER: 'supplier overseer',
|
||||
WHOLESALE_BUYER: 'wholesale buyer',
|
||||
SUPPLIER: 'supplier',
|
||||
STORE_OWNER: 'store owner',
|
||||
STORE_MANAGER: 'store manager',
|
||||
USER: 'user',
|
||||
RIDER: 'rider',
|
||||
AUDIT: 'audit',
|
||||
POS_TERMINAL: 'pos terminal',
|
||||
ANY_USER: 'default',
|
||||
PUBLIC: 'public'
|
||||
SUPER_ADMIN: 'super_admin',
|
||||
PUNONG_BARANGAY: 'punong_barangay',
|
||||
KAGAWAD: 'kagawad',
|
||||
SECRETARY: 'secretary',
|
||||
TREASURER: 'treasurer',
|
||||
SK_CHAIRPERSON: 'sk_chairperson',
|
||||
SK_COUNCILOR: 'sk_councilor',
|
||||
TANOD: 'tanod',
|
||||
BHW: 'bhw',
|
||||
DAYCARE_WORKER: 'daycare_worker',
|
||||
STAFF: 'staff',
|
||||
RESIDENT: 'resident',
|
||||
AUDIT: 'audit',
|
||||
ANY_USER: 'default',
|
||||
PUBLIC: 'public',
|
||||
};
|
||||
|
||||
export const UserTypeLabels = {
|
||||
[UserTypes.SUPER_ADMIN]: 'System Administrator',
|
||||
[UserTypes.PUNONG_BARANGAY]: 'Punong Barangay',
|
||||
[UserTypes.KAGAWAD]: 'Barangay Kagawad',
|
||||
[UserTypes.SECRETARY]: 'Barangay Secretary',
|
||||
[UserTypes.TREASURER]: 'Barangay Treasurer',
|
||||
[UserTypes.SK_CHAIRPERSON]: 'SK Chairperson',
|
||||
[UserTypes.SK_COUNCILOR]: 'SK Councilor',
|
||||
[UserTypes.TANOD]: 'Barangay Tanod',
|
||||
[UserTypes.BHW]: 'Barangay Health Worker',
|
||||
[UserTypes.DAYCARE_WORKER]: 'Daycare Worker',
|
||||
[UserTypes.STAFF]: 'Staff',
|
||||
[UserTypes.RESIDENT]: 'Resident',
|
||||
[UserTypes.AUDIT]: 'Auditor',
|
||||
[UserTypes.PUBLIC]: 'Public / Guest',
|
||||
};
|
||||
|
||||
export const ADMIN_ROLES = [
|
||||
UserTypes.SUPER_ADMIN,
|
||||
UserTypes.PUNONG_BARANGAY,
|
||||
UserTypes.SECRETARY,
|
||||
UserTypes.TREASURER,
|
||||
UserTypes.KAGAWAD,
|
||||
];
|
||||
|
||||
export const STAFF_ROLES = [
|
||||
...ADMIN_ROLES,
|
||||
UserTypes.SK_CHAIRPERSON,
|
||||
UserTypes.SK_COUNCILOR,
|
||||
UserTypes.TANOD,
|
||||
UserTypes.BHW,
|
||||
UserTypes.DAYCARE_WORKER,
|
||||
UserTypes.STAFF,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user