feat: implement barangay system phases 2-14
Some checks failed
tests / PHP 8.2 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.3 (swoole-5.1.6) (push) Has been cancelled
tests / PHP 8.4 (swoole-6.0) (push) Has been cancelled

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:
Jonathan Sykes
2026-06-07 03:09:09 +08:00
parent 19fec0933b
commit fbb7e3ff37
234 changed files with 5582 additions and 39457 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">&times;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &amp; 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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
};
}

View File

@@ -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;
}
}
});

View File

@@ -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();
}
}
}
}
})

View File

@@ -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
}
}
})

View File

@@ -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
}
}
})

View File

@@ -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,
];