initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,147 @@
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { UserTypes } 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;
}
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) {
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();
}
export function resetRole() {
globalRole.value = UserTypes.PUBLIC;
isFetching.value = false;
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) {
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);
}
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);
return {
user: currentUser,
role,
hasRole,
isUltimate,
isSuperOperator,
isOperator,
isCoordinator,
isStoreOwner,
isStoreManager,
isRider,
isSupplier,
isSupplierOverseer,
isWholesaleBuyer,
isAudit,
isUser,
isCoopOfficer,
isCoopMember,
isPOSTerminal,
isPublic,
isLoggedIn,
UserTypes,
refreshRole: fetchRole,
// Expose the user store for direct access to user data
userStore
};
}

View File

@@ -0,0 +1,309 @@
import { ref, h } from 'vue'
/**
* Global modal visibility state
* Shared across the entire application
*/
const show = ref(false)
/**
* Modal title text
* Can also be `false` to hide the header
* @type {import('vue').Ref<string|boolean>}
*/
const title = ref('')
/**
* Modal body content
* Can be a string, VNode, or Vue component
* @type {import('vue').Ref<any>}
*/
const body = ref(null)
/**
* Modal footer content
* Usually a Vue component, render function, or null
* @type {import('vue').Ref<any>}
*/
const footer = ref(null)
/**
* Global modal composable.
*
* Provides a single shared modal instance
* that can be opened, updated, and closed from anywhere.
*
* Supports string content or Vue components in the body and footer.
*
* Usage:
* ```js
* import { useModal } from '@/composables/useModal'
* import { h } from 'vue'
* import UserDetails from '@/components/UserDetails.vue'
*
* const modal = useModal()
*
* // Open with a string body
* modal.open({ title: 'Notice', body: 'Hello world' })
*
* // Open with a Vue component body
* modal.open({
* title: 'User Details',
* body: h(UserDetails, { userId: 42 })
* })
* ```
*/
export function useModal() {
/** Opens the modal */
function open({ title: t = '', body: b = null, footer: f = null } = {}) {
title.value = t
body.value = b
footer.value = f
show.value = true
}
/** Closes the modal */
function close() {
show.value = false
}
/** Update modal body dynamically */
function setBody(content) {
body.value = content
}
/** Update modal footer dynamically */
function setFooter(content) {
footer.value = content
}
/** Quickly replaces the currently open modal with new content */
function quickDismiss({ title: t = '', body: b = '', footer: f = null, condition = true, onShown = null } = {}) {
if (!condition) return
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: f })
if (typeof onShown === 'function') onShown()
}, 10)
}
/**
* Displays a modal with navigation buttons for SPA routing.
*
* Each button can pass props to the destination page component.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {Array<Array>} options.buttons - Buttons array:
* [pageName, buttonText, target?, props?]
* Example: ['Users.View', 'View User', 0, { userId: 42 }]
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
/**
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {Array<Array>} options.buttons - Buttons array:
* [pageName, buttonText, target?, props?]
* @param {Function} options.navigate - The navigate function from useNavigate()
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
function quickDismissGoto({ title: t = '', body: b = '', buttons = [], navigate = null, condition = true, onShown = null } = {}) {
if (!condition) return
const normalized = Array.isArray(buttons[0]) ? buttons : [buttons]
const FooterComponent = {
render() {
const renderedButtons = normalized.map(btn => {
const page = btn[0]
const text = btn[1] || 'Go to Page'
const props = btn[3] || {}
return h('button', {
class: 'btn btn-primary flex-fill py-2 rounded-3 shadow-sm fw-bold',
onClick: () => {
close()
if (typeof navigate === 'function') navigate({ page, props })
}
}, text)
})
return h('div', { class: 'd-flex w-100 gap-3' }, renderedButtons)
}
}
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: FooterComponent })
if (typeof onShown === 'function') onShown()
}, 10)
}
/**
* Displays a Yes / No modal with custom callbacks.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body
* @param {string} options.yesText - Yes button text
* @param {Function} options.onYes - Yes callback
* @param {string} options.noText - No button text
* @param {Function} options.onNo - No callback
* @param {boolean} [options.condition=true] - Show modal if true
* @param {Function} [options.onShown] - Callback after modal opens
*/
function yesNoModal({
title: t = '',
body: b = '',
yesText = 'Yes',
yesClass = 'btn btn-primary w-50 py-2 rounded-3 shadow-sm fw-bold',
onYes = null,
noText = 'No',
noClass = 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
onNo = null,
condition = true,
onShown = null
} = {}) {
if (!condition) return
const FooterComponent = {
render() {
return h('div', { class: 'd-flex w-100 gap-3' }, [
h('button', { class: noClass, onClick: () => { close(); onNo?.() } }, noText),
h('button', { class: yesClass, onClick: () => { close(); onYes?.() } }, yesText)
])
}
}
show.value = false
setTimeout(() => {
open({ title: t, body: b, footer: FooterComponent })
onShown?.()
}, 10)
}
/**
* Displays a Continue / Cancel modal.
*
* @param {Object} options
* @param {string} options.title - Modal title
* @param {string|VNode|Component} options.body - Modal body content
* @param {Function} options.onContinue - Callback when Continue is clicked
* @param {string} [options.continueText='Continue'] - Continue button text
* @param {string} [options.cancelText='Cancel'] - Cancel button text
* @param {string} [options.continueClass='btn btn-danger'] - Continue button CSS
* @param {string} [options.cancelClass='btn btn-warning'] - Cancel button CSS
* @param {boolean} [options.condition=true] - Only show if true
* @param {Function|null} [options.onShown] - Callback after modal opens
*
* @example
* modal.continueCancelModal({
* title: 'Unsaved Changes',
* body: 'You have unsaved changes. Continue?',
* onContinue: () => saveAndProceed()
* })
*/
function continueCancelModal({
title = '',
body = '',
onContinue = null,
continueText = 'Continue',
cancelText = 'Cancel',
continueClass = 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
cancelClass = 'btn btn-light w-50 py-2 rounded-3 border fw-bold text-muted',
condition = true,
showCancel = true,
onShown = null
} = {}) {
if (condition !== true) return
const FooterComponent = {
render() {
const buttons = []
// Cancel button
if (showCancel) {
buttons.push(
h(
'button',
{
class: cancelClass,
onClick: () => {
close()
}
},
cancelText
)
)
}
// Continue button
buttons.push(
h(
'button',
{
class: continueClass,
onClick: () => {
close()
if (typeof onContinue === 'function') {
onContinue()
}
}
},
continueText
)
)
return h('div', { class: 'd-flex w-100 gap-3' }, buttons)
}
}
// Close any existing modal first
show.value = false
setTimeout(() => {
open({
title,
body,
footer: FooterComponent
})
if (typeof onShown === 'function') {
onShown()
}
}, 10)
}
/**
* Hides the currently open modal.
* Vue replacement for hideallmodals() and hidemodal()
*/
function hideModal() {
close()
}
/**
* Alias for hideModal (migration helper)
*/
function hideAllModals() {
close()
}
return {
show,
title,
body,
footer,
open,
close,
setBody,
setFooter,
quickDismiss,
quickDismissGoto,
yesNoModal,
continueCancelModal,
hideModal,
hideAllModals,
}
}

View File

@@ -0,0 +1,227 @@
// resources/js/composables/Core/useNavigate.js
import { getCurrentInstance, h } from 'vue'
import { encodeHash, encodePayload } from '../useUrlEncoder'
import { useUIStore } from '../../stores/ui'
import { useModal } from './useModal'
// Page module map — populated once from app.js for prefetching
let _pageModules = null;
export function setPageModules(modules) { _pageModules = modules; }
// Track which pages have already been prefetched to avoid duplicate calls
const _prefetchedPages = new Set();
export function useNavigate({ currentPage, currentProps, loading } = {}) {
const instance = getCurrentInstance()
const uiStore = useUIStore()
const modal = useModal()
// Try to get the global navigate helper first from window.$navigateHelper,
// then fall back to instance?.proxy?.$navigate
const globalNavigate = (window.$navigateHelper || instance?.proxy?.$navigate)
/**
* Prefetch a page component chunk by name.
* Call on hover/focus to start loading the JS bundle before navigation.
*/
const prefetch = (pageName) => {
if (!pageName || !_pageModules || _prefetchedPages.has(pageName)) return;
const loader = _pageModules[pageName];
if (loader) {
_prefetchedPages.add(pageName);
loader().catch(() => { /* chunk may not exist, ignore */ });
}
}
const navigate = async (arg1, arg2 = {}) => {
// Universal navigate handler that accepts:
// 1. navigate({ page: 'Home', props: {} })
// 2. navigate('Home', { target: '...' })
let page, props, delay, fromPopState;
if (typeof arg1 === 'string') {
page = arg1;
props = arg2.props || arg2 || {};
delay = arg2.delay || 0;
fromPopState = arg2.fromPopState || false;
} else {
({ page, props = {}, delay = 0, fromPopState = false } = arg1);
}
if (!page) {
console.error('[useNavigate] Navigation failed: No page specified.', { arg1, arg2 });
return;
}
// Global Page Disabled Check — fire-and-forget refresh if stale (don't block navigation)
const cacheThreshold = 30 * 1000; // 30 seconds
const isStale = !uiStore.lastSynced || (new Date() - new Date(uiStore.lastSynced) > cacheThreshold);
if (isStale) {
// Non-blocking: refresh in background, don't await
uiStore.refreshSettings().catch(e => {
console.error('[useNavigate] Failed to refresh settings during navigation:', e);
});
}
const pageLower = page.toLowerCase();
const isDisabled = uiStore.disabledPages && uiStore.disabledPages.some(p => p.toLowerCase() === pageLower);
if (isDisabled) {
console.warn(`[useNavigate] Page ${page} is globally disabled.`);
modal.open({
title: 'Access Restricted',
body: h('div', { class: 'text-center p-4' }, [
h('div', { class: 'icon-shape bg-soft-danger text-danger rounded-circle mx-auto mb-4 p-3 shadow-sm' }, [
h('i', { class: 'fas fa-lock fa-3x' })
]),
h('h4', { class: 'fw-black mb-3 text-dark' }, 'Page Offline'),
h('p', { class: 'text-muted mb-4 px-3' }, `The requested page (${page}) has been temporarily disabled by the system administrator.`),
h('button', {
class: 'btn btn-primary rounded-pill px-5 py-2 fw-bold shadow-primary-sm',
onClick: () => {
modal.close();
if (pageLower !== 'home') navigate({ page: 'Home' });
}
}, 'Back to Dashboard')
]),
footer: null
});
if (loading) loading.value = false;
return;
}
// If local refs are provided (root app usage), update them directly
if (currentPage || currentProps || loading) {
if (loading) loading.value = true
if (delay) await new Promise(r => setTimeout(r, delay))
if (currentPage) currentPage.value = page
if (currentProps) currentProps.value = props
// Update browser URL (only if NOT coming from a popstate event)
if (!fromPopState) {
let url;
if (page === 'Home') {
url = '/';
} else if (page === 'Auth.Login') {
url = '/login';
} else {
// Check for special props that should be encoded into URL
const hashkeyProp = props?.target || props?.target_user || props?.hashkey || props?.id;
const payloadProp = props?.payload;
let basePageUrl = `/${page
.replace(/\./g, '/')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase()}`;
if (hashkeyProp) {
// Encode the hashkey into URL as --h:ENCODED
const encodedHash = encodeHash(hashkeyProp);
url = `${basePageUrl}--${encodedHash}`;
} else if (payloadProp) {
// Encode the payload into URL as --e:ENCODED_PAYLOAD
const encodedPayload = encodePayload(payloadProp);
url = `${basePageUrl}--${encodedPayload}`;
} else {
url = basePageUrl;
}
}
window.history.pushState({ page, props }, '', url);
}
if (loading) loading.value = false
return
}
// In child components, fall back to the global $navigate helper
if (globalNavigate) {
globalNavigate({ page, props, delay, fromPopState })
} else {
console.warn('[useNavigate] No local refs and global navigate helper not found.')
}
}
const reloadPage = () => {
if (currentPage) {
navigate({ page: currentPage.value, props: currentProps?.value, delay: 0 })
} else if (globalNavigate) {
// In children, we can't easily get 'currentPage' unless we pass it or use a store
// but usually reloadPage is used in the context that knows the current state.
console.warn('[useNavigate] reloadPage called in child without refs.')
}
}
return { navigate, reloadPage, prefetch }
}
/**
* Helper to derive component name from a URL path in a generic way.
* Matches the logic in VueRouteMap.php.
*/
const getPageFromUrl = (path) => {
let cleanPath = path.trim().replace(/^\/+|\/+$/g, '');
if (!cleanPath) return 'Home';
// Handle explicit common mappings
if (cleanPath === 'login') return 'Auth.Login';
if (cleanPath === 'app') return 'Home';
// Strip hash/payload suffix (e.g., "view-store-market--h:ABC" -> "view-store-market")
const slug = cleanPath.split('--h:')[0].split('--e:')[0];
// Convert kebab-case/path to PascalCase/dots (e.g., "market/list-products" -> "Market.ListProducts")
return slug.split('/').map(part => {
return part.split('-').map(subPart => {
if (!subPart) return '';
return subPart.charAt(0).toUpperCase() + subPart.slice(1);
}).join('');
}).join('.');
}
// Global popstate listener to handle browser back button
if (typeof window !== 'undefined') {
window.addEventListener('popstate', (event) => {
if (event.state && event.state.page) {
if (window.$navigateHelper) {
window.$navigateHelper({
page: event.state.page,
props: event.state.props,
fromPopState: true,
delay: 0
})
} else {
// Fallback if app not yet initialized
window.location.reload()
}
} else {
// If no state (initial load entry point), attempt to restore from initial DOM payload
const el = document.getElementById('main-body');
if (el && el.dataset.page) {
try {
const initialPage = JSON.parse(el.dataset.page);
if (window.$navigateHelper) {
window.$navigateHelper({
page: initialPage.component,
props: initialPage.props,
fromPopState: true,
delay: 0
});
return;
}
} catch (e) {
console.error('[useNavigate] Failed to parse initial page from DOM:', e);
}
}
// Ultimate Fallback: Derive the page from the URL (without props, as we can't reliably decode them here)
const page = getPageFromUrl(window.location.pathname);
if (window.$navigateHelper) {
window.$navigateHelper({ page, fromPopState: true, delay: 0 })
}
}
})
}

View File

@@ -0,0 +1,17 @@
import { onMounted, onUnmounted } from 'vue'
import { useUIStore } from '../../stores/ui'
export function usePageTitle(title) {
const uiStore = useUIStore()
onMounted(() => {
if (title) {
uiStore.setPageTitle(title)
}
})
return {
setTitle: (newTitle) => uiStore.setPageTitle(newTitle),
resetTitle: () => uiStore.resetPageTitle()
}
}

View File

@@ -0,0 +1,115 @@
// resources/js/composables/Core/usePrefetch.js
import { useNavigate } from './useNavigate';
import { useAuth } from './useAuth';
import axios from 'axios';
import { usePrefetchStore } from '../../stores/prefetch';
export function usePrefetch() {
const { prefetch: prefetchJS } = useNavigate();
const { hasRole, UserTypes } = useAuth();
const prefetchStore = usePrefetchStore();
// Registry of pages and their corresponding data endpoints
// Each entry: { page: string, dataUrls: Array<{ url: string, method: string, payload: object }>, roles: string[] }
const registry = [
{
page: 'UserList',
dataUrls: [
{ url: '/admin/users/list', method: 'GET' }
],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ManageStoresAdmin',
dataUrls: [{ url: '/Admin/Stores/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ListStores',
dataUrls: [{ url: '/ListStores/List/data', method: 'POST' }],
roles: 'all'
},
{
page: 'ManageProductsAdmin',
dataUrls: [{ url: '/Admin/Products/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
},
{
page: 'ShipmentList',
dataUrls: [{ url: '/Shipments/List', method: 'POST' }],
roles: 'all'
},
{
page: 'CooperativeList',
dataUrls: [{ url: '/Cooperatives/List', method: 'POST' }],
roles: 'all'
},
{
page: 'VerificationDashboard',
dataUrls: [{ url: '/Farmers/List', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR, UserTypes.OPERATOR]
},
{
page: 'ListReports',
dataUrls: [{ url: '/admin/accounting/reports', method: 'POST' }],
roles: [UserTypes.ULTIMATE, UserTypes.SUPER_OPERATOR]
}
];
/**
* Prefetch a single data endpoint and store it in the persistent cache.
*/
const prefetchData = async ({ url, method = 'GET', payload = {} }) => {
try {
const cacheKey = `${method}:${url}:${JSON.stringify(payload)}`;
// Standardizing the request
let response;
if (method.toUpperCase() === 'POST') {
response = await axios.post(url, payload);
} else {
response = await axios.get(url, { params: payload });
}
if (response.data) {
prefetchStore.setCache(cacheKey, response.data);
console.log(`[Prefetch] Cached ${url}`);
}
} catch (error) {
console.warn(`[Prefetch] Failed to prefetch data for ${url}:`, error);
}
};
/**
* Trigger prefetching for ALL allowed modules.
* Staggered to avoid network congestion.
*/
const prefetchEverything = async () => {
const allowedModules = registry.filter(item => {
if (item.roles === 'all') return true;
return item.roles.some(role => hasRole(role));
});
console.log(`[Prefetch] Starting universal prefetch for ${allowedModules.length} modules.`);
// Process in a queue with small delay to avoid hammering
for (const module of allowedModules) {
// 1. Prefetch JS Chunk
prefetchJS(module.page);
// 2. Prefetch Data Endpoints
for (const dataInfo of module.dataUrls) {
// Background fetch, don't wait for completion before starting next module
prefetchData(dataInfo);
}
// Small break between modules
await new Promise(r => setTimeout(r, 200));
}
};
return {
prefetchEverything,
prefetchData
};
}

View File

@@ -0,0 +1,360 @@
/**
* Session Guard Composable
*
* Connects to /sse/stream to receive real-time updates for session validity,
* user notes, and executive commands.
* When the session is gone, it clears all local auth state and reloads
* the page so the user lands on the login screen.
*
* FALLBACK: If SSE is unavailable (insecure context, EventSource errors),
* automatically switches to a Web Worker that polls /get/isloggedin and
* /get/isExec at regular intervals.
*/
import { onMounted, onUnmounted } from 'vue';
import { useUserStore } from '../../stores/user.js';
import { usePosStore } from '../../stores/pos.js';
import { useUIStore } from '../../stores/ui.js';
import { useProductStore } from '../../stores/product.js';
const GUARD_ACTIVE_KEY = 'session_guard_active';
// Singleton guard — only one EventSource across the entire SPA
let eventSource = null;
let pollingWorker = null;
let guardStarted = false;
let sseFailCount = 0;
const MAX_SSE_RETRIES = 1;
/**
* Clear all local authentication artifacts and force reload to login.
* Exported so the axios interceptor can call it without duplicating logic.
*/
export function handleSessionExpired() {
console.warn('[SessionGuard] Session expired — clearing local state and redirecting to login.');
// Clear sessionStorage auth data
sessionStorage.removeItem('user_acct_type');
sessionStorage.removeItem('currentUser');
sessionStorage.removeItem(GUARD_ACTIVE_KEY);
// Clear any localStorage auth tokens if they exist
localStorage.removeItem('auth_token');
localStorage.removeItem('jwt_token');
localStorage.removeItem('token');
// Stop the connection
stopGuard();
// Force full page reload to login
window.location.href = '/login';
}
/**
* Start the SSE connection (idempotent — safe to call multiple times).
* Exported so callers can re-trigger the guard after the user's account type
* is confirmed (e.g., right after fetchCurrentUser resolves with a real user).
*/
export function startGuard() {
if (guardStarted) return;
// Don't connect on login/public pages — would cause redirect loops
const path = window.location.pathname.toLowerCase();
if (path === '/login' || path === '/sp/login') return;
// Don't connect for guests. The guard exists to detect an *expired*
// authenticated session — for someone who was never logged in there is
// nothing to guard, and connecting would cause /sse/stream to immediately
// report isloggedin:false and bounce the visitor to /login.
const acctType = sessionStorage.getItem('user_acct_type');
if (!acctType || acctType === 'public') return;
guardStarted = true;
sseFailCount = 0;
sessionStorage.setItem(GUARD_ACTIVE_KEY, '1');
connectSSE();
checkExec();
}
/**
* Fetch and execute any pending executive command once.
*/
async function checkExec() {
try {
const response = await fetch('/get/isExec');
if (response.ok) {
const command = await response.text();
if (command && command.trim()) {
const userStore = useUserStore();
userStore.executeCommand(command);
}
}
} catch (e) {
console.warn('[SessionGuard] Failed to fetch initial exec command:', e);
}
}
// Tracking the current build/app version
let currentVersion = null;
function connectSSE() {
if (eventSource) {
eventSource.close();
}
// Initialize current version from initial page if not yet set
if (!currentVersion) {
const el = document.getElementById('main-body');
if (el && el.dataset.page) {
try {
const pageData = JSON.parse(el.dataset.page);
currentVersion = pageData.version;
} catch (e) {}
}
}
// Check if EventSource is available at all
if (typeof EventSource === 'undefined') {
console.warn('[SessionGuard] EventSource not available, switching to Web Worker polling fallback.');
startPollingFallback();
return;
}
try {
eventSource = new EventSource('/sse/stream');
} catch (e) {
console.warn('[SessionGuard] Failed to create EventSource, switching to Web Worker polling fallback.', e);
startPollingFallback();
return;
}
eventSource.onmessage = (event) => {
// Reset fail count on successful message
sseFailCount = 0;
try {
const data = JSON.parse(event.data);
if (data.isloggedin === false) {
handleSessionExpired();
return;
}
// Reload page if a new asset version is detected
if (data.version && currentVersion && data.version !== currentVersion) {
console.warn('[SessionGuard] New asset version detected:', data.version, 'Local version:', currentVersion, '. Reloading...');
window.location.reload();
return;
}
// If version hasn't been set yet (shouldn't happen with the check above, but for safety)
if (data.version && !currentVersion) {
currentVersion = data.version;
}
const userStore = useUserStore();
const posStore = usePosStore();
const uiStore = useUIStore();
const productStore = useProductStore();
if (data.notes) {
userStore.setNotes(data.notes);
}
if (data.exec) {
userStore.executeCommand(data.exec);
}
if (data.disabled_pages) {
uiStore.syncDisabledPages(data.disabled_pages);
}
if (data.pos_stats || data.customers || data.inventory_deltas) {
posStore.syncFromSSE(data);
}
if (data.products_market || data.inventory_deltas) {
productStore.syncFromSSE(data);
}
} catch (e) {
console.error('[SessionGuard] Failed to parse SSE data:', e);
}
};
eventSource.onerror = (error) => {
sseFailCount++;
console.warn('[SessionGuard] SSE connection error (' + sseFailCount + '/' + (MAX_SSE_RETRIES + 1) + ').', error);
if (sseFailCount > MAX_SSE_RETRIES) {
// SSE is not working reliably — switch to Web Worker polling
console.warn('[SessionGuard] SSE unavailable after retries, switching to Web Worker polling fallback.');
if (eventSource) {
eventSource.close();
eventSource = null;
}
startPollingFallback();
return;
}
if (eventSource && eventSource.readyState === EventSource.CLOSED) {
// One-time check if we're actually logged out (401)
fetch('/get/isloggedin').then(res => {
if (res.status === 401 || res.status === 419) {
handleSessionExpired();
}
}).catch(() => {});
}
};
}
/**
* Start the Web Worker polling fallback.
* Used when SSE (EventSource) is unavailable or keeps failing.
*/
function startPollingFallback() {
if (pollingWorker) return;
// Check Web Worker support
if (typeof Worker === 'undefined') {
console.error('[SessionGuard] Neither EventSource nor Web Workers available. Falling back to setInterval polling on main thread.');
startMainThreadFallback();
return;
}
try {
pollingWorker = new Worker(
new URL('../../workers/session-guard-worker.js', import.meta.url),
{ type: 'classic' }
);
} catch (e) {
console.warn('[SessionGuard] Failed to create Web Worker, falling back to main-thread polling.', e);
startMainThreadFallback();
return;
}
console.info('[SessionGuard] Web Worker polling fallback started.');
pollingWorker.onmessage = (event) => {
const { type, data } = event.data || {};
if (type === 'session') {
if (data && data.isloggedin === false) {
handleSessionExpired();
return;
}
}
if (type === 'exec') {
if (data && typeof data === 'string' && data.trim()) {
const userStore = useUserStore();
userStore.executeCommand(data);
}
}
};
pollingWorker.onerror = (err) => {
console.error('[SessionGuard] Web Worker error:', err);
// If the worker itself fails, fall back to main thread
stopPollingWorker();
startMainThreadFallback();
};
// Send start command (worker also auto-starts, but this is explicit)
pollingWorker.postMessage({ type: 'start' });
}
/**
* Last-resort fallback: poll on the main thread using setInterval.
* Used when neither SSE nor Web Workers are available.
*/
let mainThreadLoginTimer = null;
let mainThreadExecTimer = null;
function startMainThreadFallback() {
if (mainThreadLoginTimer) return;
console.info('[SessionGuard] Main-thread polling fallback started.');
const checkLoginMain = async () => {
try {
const res = await fetch('/get/isloggedin');
if (res.ok) {
const data = await res.json();
if (data.isloggedin === false) {
handleSessionExpired();
}
} else if (res.status === 401 || res.status === 419) {
handleSessionExpired();
}
} catch (e) { /* skip */ }
};
const checkExecMain = async () => {
try {
const res = await fetch('/get/isExec');
if (res.ok) {
const text = await res.text();
if (text && text.trim()) {
const userStore = useUserStore();
userStore.executeCommand(text);
}
}
} catch (e) { /* skip */ }
};
// Run immediately
checkLoginMain();
checkExecMain();
mainThreadLoginTimer = setInterval(checkLoginMain, 10000);
mainThreadExecTimer = setInterval(checkExecMain, 30000);
}
function stopMainThreadFallback() {
if (mainThreadLoginTimer) { clearInterval(mainThreadLoginTimer); mainThreadLoginTimer = null; }
if (mainThreadExecTimer) { clearInterval(mainThreadExecTimer); mainThreadExecTimer = null; }
}
function stopPollingWorker() {
if (pollingWorker) {
try { pollingWorker.postMessage({ type: 'stop' }); } catch (e) {}
pollingWorker.terminate();
pollingWorker = null;
}
}
/**
* Stop the SSE connection and any fallback mechanisms.
*/
function stopGuard() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
stopPollingWorker();
stopMainThreadFallback();
guardStarted = false;
sseFailCount = 0;
sessionStorage.removeItem(GUARD_ACTIVE_KEY);
}
/**
* Vue composable to activate the session guard within a component lifecycle.
* Typically used in the root App or a layout component.
*/
export function useSessionGuard() {
onMounted(() => {
startGuard();
});
onUnmounted(() => {
// In SPA root this won't fire, but be tidy for sub-components
stopGuard();
});
return {
startGuard,
stopGuard,
};
}

View File

@@ -0,0 +1,162 @@
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

@@ -0,0 +1,53 @@
import { ref } from 'vue';
import axios from 'axios';
export function useActivity() {
const activities = ref([]);
const loading = ref(false);
const error = ref(null);
const fetchRecentActivities = async (limit = 10) => {
loading.value = true;
error.value = null;
try {
const response = await axios.get('/api/activity/recent', {
params: { limit }
});
if (response.data.success) {
activities.value = response.data.data;
} else {
error.value = 'Failed to fetch activities';
}
} catch (err) {
error.value = err.message || 'Error connecting to activity service';
console.error('Activity fetch error:', err);
} finally {
loading.value = false;
}
};
const searchActivities = async (query, limit = 20) => {
loading.value = true;
error.value = null;
try {
const response = await axios.get('/api/activity/search', {
params: { q: query, limit }
});
if (response.data.success) {
activities.value = response.data.data;
}
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
return {
activities,
loading,
error,
fetchRecentActivities,
searchActivities
};
}

View File

@@ -0,0 +1,130 @@
import { ref } from 'vue';
import axios from 'axios';
/**
* Composable for managing global announcements
*/
export function useAnnouncements() {
const announcements = ref([]);
const loading = ref(false);
const error = ref(null);
/**
* Fetch the latest active announcements for users
*/
const fetchLatest = async () => {
loading.value = true;
error.value = null;
try {
const response = await axios.get('/Announcements/Latest');
announcements.value = response.data || [];
} catch (err) {
console.error('Error fetching announcements:', err);
error.value = 'Failed to load announcements.';
} finally {
loading.value = false;
}
};
/**
* List all announcements (for admin management)
*/
const fetchAllAdmin = async () => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Admin/Announcements/List');
return response.data || [];
} catch (err) {
console.error('Error fetching admin announcements:', err);
error.value = 'Failed to fetch admin announcements.';
return [];
} finally {
loading.value = false;
}
};
/**
* Store a new announcement
*/
const storeAnnouncement = async (data) => {
try {
const response = await axios.post('/Admin/Announcement/Store', data);
return response.data;
} catch (err) {
console.error('Error storing announcement:', err);
throw err;
}
};
/**
* Update an announcement
*/
const updateAnnouncement = async (data) => {
try {
const response = await axios.post('/Admin/Announcement/Update', data);
return response.data;
} catch (err) {
console.error('Error updating announcement:', err);
throw err;
}
};
/**
* Delete an announcement
*/
const deleteAnnouncement = async (target) => {
try {
const response = await axios.post('/Admin/Announcement/Delete', { target });
return response.data;
} catch (err) {
console.error('Error deleting announcement:', err);
throw err;
}
};
/**
* Toggle active status of an announcement
*/
const toggleStatus = async (target) => {
try {
const response = await axios.post('/Admin/Announcement/ToggleStatus', { target });
return response.data;
} catch (err) {
console.error('Error toggling status:', err);
throw err;
}
};
/**
* Upload an announcement photo
*/
const uploadPhoto = async (file) => {
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/File/Upload/announcement', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (err) {
console.error('Error uploading photo:', err);
throw err;
}
};
return {
announcements,
loading,
error,
fetchLatest,
fetchAllAdmin,
storeAnnouncement,
updateAnnouncement,
deleteAnnouncement,
toggleStatus,
uploadPhoto
};
}

View File

@@ -0,0 +1,220 @@
import { ref } from 'vue';
import axios from 'axios';
export function useChapters() {
const loading = ref(false);
const error = ref(null);
const fetchHierarchy = async ({ chapterId = null } = {}) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/Hierarchy', { chapter_id: chapterId });
return response.data;
} catch (err) {
error.value = 'Failed to load chapter hierarchy.';
console.error(err);
return { chapters: [], current: null, breadcrumb: [] };
} finally {
loading.value = false;
}
};
const fetchMapData = async ({ level = 'region', parentId = null } = {}) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/MapData', { level, parent_id: parentId });
return response.data;
} catch (err) {
error.value = 'Failed to load map data.';
console.error(err);
return { chapters: [] };
} finally {
loading.value = false;
}
};
const fetchOrgHierarchy = async ({ chapterId = null, island = null } = {}) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/OrgHierarchy', { chapter_id: chapterId, island });
return response.data;
} catch (err) {
error.value = 'Failed to load organization hierarchy.';
console.error(err);
return { chapters: [], current: null, breadcrumb: [] };
} finally {
loading.value = false;
}
};
const fetchOrgMapData = async ({ level = 'island_group', parentId = null, island = null } = {}) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/OrgMapData', { level, parent_id: parentId, island });
return response.data;
} catch (err) {
error.value = 'Failed to load organization map data.';
console.error(err);
return { chapters: [] };
} finally {
loading.value = false;
}
};
const fetchMembers = async (chapterId) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/Members', { chapter_id: chapterId });
return response.data;
} catch (err) {
error.value = 'Failed to load members.';
console.error(err);
return { members: [] };
} finally {
loading.value = false;
}
};
const assignMember = async ({ userHashkey, chapterId, position = null, isManual = true }) => {
try {
const response = await axios.post('/Chapters/Member/Assign', {
user_hashkey: userHashkey,
chapter_id: chapterId,
position,
is_manual: isManual,
});
return response.data;
} catch (err) {
console.error(err);
throw err;
}
};
const removeMember = async (hashkey) => {
try {
const response = await axios.post('/Chapters/Member/Remove', { hashkey });
return response.data;
} catch (err) {
console.error(err);
throw err;
}
};
const fetchPositions = async () => {
try {
const response = await axios.get('/Chapters/Positions');
return response.data.positions ?? [];
} catch (err) {
console.error(err);
return [];
}
};
const syncAutoAssignments = async () => {
try {
const response = await axios.post('/Chapters/SyncAutoAssignments');
return response.data;
} catch (err) {
console.error(err);
throw err;
}
};
const fetchOrgChart = async ({ chapterId = null } = {}) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/OrgChart', { chapter_id: chapterId });
return response.data;
} catch (err) {
error.value = 'Failed to load org chart.';
console.error(err);
return { own_chapter: null, children: [] };
} finally {
loading.value = false;
}
};
const fetchOfficerScope = async () => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/Officer/Scope', {});
return response.data;
} catch (err) {
error.value = 'Failed to load chapter scope.';
console.error(err);
return { own_chapter: null, child_chapters: [], cooperative: null, eligible_members: [] };
} finally {
loading.value = false;
}
};
const searchMembers = async (query) => {
loading.value = true;
error.value = null;
try {
const response = await axios.post('/Chapters/Members/Search', { query });
return response.data?.members ?? [];
} catch (err) {
error.value = 'Failed to search members.';
console.error(err);
return [];
} finally {
loading.value = false;
}
};
const assignOfficer = async ({ memberUserHashkey, childChapterId, role }) => {
try {
const response = await axios.post('/Chapters/Officer/Assign', {
member_user_hashkey: memberUserHashkey,
child_chapter_id: childChapterId,
role,
});
return response.data;
} catch (err) {
console.error(err);
throw err;
}
};
const createChapter = async ({ name, locationKey, lat = null, lng = null }) => {
try {
const response = await axios.post('/Chapters/Create', {
name,
location_key: locationKey,
lat,
lng,
});
return response.data;
} catch (err) {
console.error(err);
throw err;
}
};
return {
loading,
error,
fetchHierarchy,
fetchMapData,
fetchOrgHierarchy,
fetchOrgMapData,
fetchMembers,
assignMember,
removeMember,
fetchPositions,
syncAutoAssignments,
fetchOrgChart,
fetchOfficerScope,
searchMembers,
assignOfficer,
createChapter,
};
}

View File

@@ -0,0 +1,74 @@
import { ref } from 'vue';
import { useOPFS } from './useOPFS.js';
const blobCache = ref({});
const opfs = useOPFS();
export function useFileBlobCache() {
/**
* Get a blob URL for a given hash.
* Order: 1. Locally cached URL (blobCache), 2. Persistent storage (OPFS), 3. Server Fetch.
*
* @param {string} hash
* @returns {Promise<string|null>}
*/
const getFile = async (hash) => {
if (!hash) return null;
// 1. Check in local session cache
if (blobCache.value[hash]) {
return blobCache.value[hash];
}
// 2. Check in persistent OPFS storage
const opfsBlob = await opfs.loadFile(hash);
if (opfsBlob) {
const blobUrl = URL.createObjectURL(opfsBlob);
blobCache.value[hash] = blobUrl;
return blobUrl;
}
// 3. Last resort: fetch from server
try {
const url = `/RequestData/File/${hash}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.statusText}`);
}
const blob = await response.blob();
// Save to persistent OPFS storage for future use
await opfs.saveFile(hash, blob);
const blobUrl = URL.createObjectURL(blob);
// Store in local session cache
blobCache.value[hash] = blobUrl;
return blobUrl;
} catch (error) {
console.error(`Error fetching file with hash ${hash}:`, error);
return null;
}
};
/**
* Pre-cache a list of hashes.
*
* @param {string[]} hashes
*/
const preCacheFiles = async (hashes) => {
if (!hashes || !Array.isArray(hashes)) return;
const promises = hashes.map(hash => getFile(hash));
await Promise.all(promises);
};
return {
getFile,
preCacheFiles,
blobCache
};
}

View File

@@ -0,0 +1,112 @@
import { ref } from 'vue';
import axios from 'axios';
/**
* Composable for handling file uploads.
*
* @param {Object} options
* @param {string[]} options.acceptedTypes - Array of accepted MIME types (default: ['image/*'])
* @param {number} options.maxSizeMB - Maximum file size in MB (default: 10)
* @param {string} options.category - Category for the upload (default: 'general')
*/
export function useFileUpload(options = {}) {
const acceptedTypes = options.acceptedTypes || ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const maxSizeMB = options.maxSizeMB || 10;
const category = options.category || 'general';
const photoHashes = ref([]);
const isUploading = ref(false);
const uploadError = ref(null);
/**
* Upload a file to the server.
*
* @param {File} file
* @returns {Promise<Object|null>}
*/
const uploadFile = async (file) => {
// Validation
if (file.size > maxSizeMB * 1024 * 1024) {
uploadError.value = `File size exceeds ${maxSizeMB}MB limit.`;
return null;
}
const isAccepted = acceptedTypes.some(type => {
if (type.endsWith('/*')) {
const baseType = type.split('/')[0];
return file.type.startsWith(`${baseType}/`);
}
return file.type === type;
});
if (!isAccepted && acceptedTypes.length > 0) {
uploadError.value = 'Invalid file type.';
return null;
}
isUploading.value = true;
uploadError.value = null;
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post(`/File/Upload/${category}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (response.data && response.data.success && response.data.hashkey) {
const hashkey = response.data.hashkey;
photoHashes.value.push(hashkey);
return response.data;
} else {
throw new Error(response.data?.message || 'Upload failed');
}
} catch (err) {
console.error('Upload error:', err);
const status = err.response?.status;
const backendMsg = err.response?.data?.message || err.response?.data?.error;
if (status === 403) {
uploadError.value = "You don't have permission to upload files here.";
} else if (status === 413) {
uploadError.value = 'File too large.';
} else {
uploadError.value = backendMsg || err.message || 'Failed to upload file.';
}
return null;
} finally {
isUploading.value = false;
}
};
/**
* Remove a hash from the list.
*
* @param {string} hash
*/
const removeHash = (hash) => {
photoHashes.value = photoHashes.value.filter(h => h !== hash);
};
/**
* Set initial hashes (e.g., when loading existing data).
*
* @param {string[]} hashes
*/
const setInitialHashes = (hashes) => {
if (Array.isArray(hashes)) {
photoHashes.value = [...hashes];
}
};
return {
photoHashes,
isUploading,
uploadError,
uploadFile,
removeHash,
setInitialHashes
};
}

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,98 @@
// resources/js/composables/useHashKeyCache.js
import { ref } from 'vue';
const hashDataStore = ref({});
/**
* Composable for handling hashkey-based URL patterns and data caching.
* Supported Patterns:
* 1. /path--h:HASHKEY -> Fetch data associated with the hashkey
* 2. /path--e:PAYLOAD -> Direct base64-encoded JSON payload in URL
*/
export function useHashKeyCache() {
/**
* Get data associated with a hashkey.
* @param {string} key
* @returns {any|null}
*/
const getHashData = (key) => {
return hashDataStore.value[key] || null;
};
/**
* Cache data against a hashkey.
* @param {string} key
* @param {any} data
*/
const setHashData = (key, data) => {
if (key && data) {
hashDataStore.value[key] = data;
}
};
/**
* Decodes a base64 encoded payload from the URL.
* @param {string} base64Payload
* @returns {any|null}
*/
const decodePayload = (base64Payload) => {
try {
const decodedStr = atob(base64Payload);
return JSON.parse(decodedStr);
} catch (e) {
console.error('[HashCache] Failed to decode URL payload:', e);
return null;
}
};
/**
* Parse the current URL or a given URL for hashkey/payload patterns.
* @param {string} urlString
* @returns {{type: 'hash'|'payload'|'none', value: any}}
*/
const parseHashUrl = (urlString = window.location.pathname) => {
const hashMatch = urlString.match(/--h:([A-Za-z0-9_-]+)/);
if (hashMatch) {
const hashkey = hashMatch[1];
return { type: 'hash', value: hashkey, data: getHashData(hashkey) };
}
const payloadMatch = urlString.match(/--e:([A-Za-z0-9+/=_-]+)/);
if (payloadMatch) {
const payload = decodePayload(payloadMatch[1]);
return { type: 'payload', value: payloadMatch[1], data: payload };
}
return { type: 'none', value: null, data: null };
};
/**
* Formats a URL with a hashkey.
* @param {string} baseUrl
* @param {string} hash
* @returns {string}
*/
const formatHashUrl = (baseUrl, hash) => {
return `${baseUrl.replace(/\/+$/, '')}--h:${hash}`;
};
/**
* Formats a URL with an encoded payload.
* @param {string} baseUrl
* @param {any} data
* @returns {string}
*/
const formatPayloadUrl = (baseUrl, data) => {
const payload = btoa(JSON.stringify(data));
return `${baseUrl.replace(/\/+$/, '')}--e:${payload}`;
};
return {
getHashData,
setHashData,
parseHashUrl,
formatHashUrl,
formatPayloadUrl,
hashDataStore
};
}

View File

@@ -0,0 +1,131 @@
// resources/js/composables/useOPFS.js
/**
* Composable to handle Origin Private File System (OPFS) operations.
*/
export function useOPFS() {
/**
* Get the OPFS root directory.
* @returns {Promise<FileSystemDirectoryHandle>}
*/
const getRoot = async () => {
return await navigator.storage.getDirectory();
};
/**
* Save a Blob or ArrayBuffer to OPFS as a file.
* @param {string} filename
* @param {Blob|ArrayBuffer} content
* @returns {Promise<boolean>}
*/
const saveFile = async (filename, content) => {
if (!filename || !content) return false;
try {
const root = await getRoot();
const fileHandle = await root.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
return true;
} catch (error) {
console.error(`[OPFS] Error saving file "${filename}":`, error);
return false;
}
};
/**
* Load a file from OPFS as a Blob.
* @param {string} filename
* @returns {Promise<Blob|null>}
*/
const loadFile = async (filename) => {
if (!filename) return null;
try {
const root = await getRoot();
const fileHandle = await root.getFileHandle(filename);
const file = await fileHandle.getFile();
return file;
} catch (error) {
// File not finding is a common potential case, no need to log error
return null;
}
};
/**
* Check if a file exists in OPFS.
* @param {string} filename
* @returns {Promise<boolean>}
*/
const exists = async (filename) => {
if (!filename) return false;
try {
const root = await getRoot();
await root.getFileHandle(filename);
return true;
} catch (e) {
return false;
}
};
/**
* Delete a file from OPFS.
* @param {string} filename
* @returns {Promise<boolean>}
*/
const deleteFile = async (filename) => {
if (!filename) return false;
try {
const root = await getRoot();
await root.removeEntry(filename);
return true;
} catch (error) {
console.error(`[OPFS] Error deleting file "${filename}":`, error);
return false;
}
};
/**
* Save JSON data to OPFS.
* @param {string} filename
* @param {any} data
* @returns {Promise<boolean>}
*/
const saveJSON = async (filename, data) => {
if (!filename || data === undefined) return false;
try {
const jsonStr = JSON.stringify(data);
const blob = new Blob([jsonStr], { type: 'application/json' });
return await saveFile(filename, blob);
} catch (error) {
console.error(`[OPFS] Error saving JSON "${filename}":`, error);
return false;
}
};
/**
* Load JSON data from OPFS.
* @param {string} filename
* @returns {Promise<any|null>}
*/
const loadJSON = async (filename) => {
if (!filename) return null;
try {
const blob = await loadFile(filename);
if (!blob) return null;
const text = await blob.text();
return JSON.parse(text);
} catch (error) {
console.error(`[OPFS] Error loading JSON "${filename}":`, error);
return null;
}
};
return {
saveFile,
loadFile,
saveJSON,
loadJSON,
exists,
deleteFile
};
}

View File

@@ -0,0 +1,176 @@
import { ref, computed } from 'vue';
import { db } from '../db';
import axios from 'axios';
import { useNetworkStore } from '../stores/network';
const isSyncing = ref(false);
const syncError = ref(null);
const syncErrors = ref([]);
const lastSyncTime = ref(localStorage.getItem('last_pos_sync') || null);
const pendingTransactionsCount = ref(0);
export function useOfflineStore() {
const networkStore = useNetworkStore();
const isOnline = computed(() => networkStore.isOnline);
const updatePendingCount = async () => {
const count = await db.pending_transactions
.where('status')
.equals('PENDING')
.count();
pendingTransactionsCount.value = count;
return count;
};
/**
* Pull products from server and update local DB
*/
const syncProducts = async (storeHash) => {
if (!isOnline.value) return;
try {
isSyncing.value = true;
const response = await axios.get('/POS/GetProducts', {
params: { target: storeHash }
});
if (response.data && response.data.products) {
// Bulk put (overwrite existing)
await db.products.bulkPut(response.data.products);
lastSyncTime.value = new Date().toISOString();
localStorage.setItem('last_pos_sync', lastSyncTime.value);
}
} catch (err) {
console.error('[OfflineStore] Failed to sync products:', err);
syncError.value = err.message;
} finally {
isSyncing.value = false;
}
};
/**
* Store a completed transaction locally
*/
const storeTransactionOffline = async (txnData) => {
const result = await db.pending_transactions.add({
...txnData,
timestamp: new Date().toISOString(),
status: 'PENDING'
});
await updatePendingCount();
return result;
};
/**
* Push all pending local transactions to the server
*/
const pushPendingTransactions = async () => {
if (!networkStore.isOnline || isSyncing.value) return 0;
const pendings = await db.pending_transactions
.where('status')
.equals('PENDING')
.toArray();
if (pendings.length === 0) {
await updatePendingCount();
return 0;
}
isSyncing.value = true;
let syncedCount = 0;
syncErrors.value = [];
try {
const validPendings = pendings.filter(txn => txn.store_hash);
const skippedCount = pendings.length - validPendings.length;
if (skippedCount > 0) {
console.warn(`[OfflineStore] Skipping ${skippedCount} transactions with missing store_hash`);
}
if (validPendings.length === 0) {
syncErrors.value = ['No valid transactions to sync (missing store reference)'];
return 0;
}
const response = await axios.post('/api/pos/sync-offline', {
transactions: validPendings.map(txn => ({
local_id: txn.id,
store_hash: txn.store_hash,
customer_name: txn.customer_name,
items: txn.items,
total: txn.total,
received: txn.received,
method: txn.method,
timestamp: txn.timestamp
}))
});
if (response.data && response.data.success) {
const { synced_count, synced_ids, errors } = response.data.data;
syncedCount = synced_count;
if (errors && errors.length > 0) {
console.error('[OfflineStore] Server sync errors:', errors);
syncErrors.value = errors;
}
if (synced_ids && synced_ids.length > 0) {
await db.pending_transactions.bulkUpdate(synced_ids.map(id => ({
key: id,
changes: { status: 'SYNCED' }
})));
await db.pending_transactions.where('status').equals('SYNCED').delete();
}
lastSyncTime.value = new Date().toLocaleTimeString();
}
} catch (error) {
console.error('Offline Sync Failed:', error);
syncErrors.value = [error?.response?.data?.message || error.message || 'Sync request failed'];
} finally {
isSyncing.value = false;
await updatePendingCount();
}
return syncedCount;
};
/**
* Search products locally
*/
const searchProductsLocally = async (query, category = 'All') => {
let collection = db.products;
if (category !== 'All') {
collection = collection.where('category').equals(category);
} else {
collection = collection.toCollection();
}
const products = await collection.toArray();
if (!query) return products;
const lowerQuery = query.toLowerCase();
return products.filter(p =>
p.name.toLowerCase().includes(lowerQuery) ||
(p.barcode && p.barcode.includes(lowerQuery))
);
};
return {
isSyncing,
syncError,
syncErrors,
lastSyncTime,
pendingTransactionsCount,
updatePendingCount,
syncProducts,
storeTransactionOffline,
pushPendingTransactions,
searchProductsLocally
};
}

View File

@@ -0,0 +1,62 @@
// resources/js/composables/usePageData.js
import { ref } from 'vue';
import axios from 'axios';
import { usePrefetchStore } from '../stores/prefetch';
export default function usePageData() {
const data = ref(null);
const loading = ref(false);
const error = ref(false);
const stale = ref(false);
const prefetchStore = usePrefetchStore();
/**
* Fetch page data with Cache-First strategy.
* Checks persistent prefetchStore before hitting network.
*/
const fetchPageData = async (url, payload = {}, method = 'GET') => {
loading.value = true;
error.value = false;
stale.value = false;
const cacheKey = `${method.toUpperCase()}:${url}:${JSON.stringify(payload)}`;
// 1. Try to get cached data first (Persistent & 6-month valid)
if (prefetchStore.hasCache(cacheKey)) {
data.value = prefetchStore.getCache(cacheKey);
stale.value = true;
loading.value = false; // We have SOMETHING to show immediately
}
try {
// 2. Fetch fresh data from network
let response;
if (method.toUpperCase() === 'POST') {
response = await axios.post(url, payload);
} else {
response = await axios.get(url, { params: payload });
}
if (response.data) {
// Adapt to backend format (Inertia-like or raw)
const freshData = response.data.props || response.data;
data.value = freshData;
// 3. Update persistent cache
prefetchStore.setCache(cacheKey, freshData);
stale.value = false;
}
} catch (err) {
console.warn(`[usePageData] Failed to fetch fresh data for ${url}:`, err);
if (!data.value) {
error.value = true;
}
} finally {
loading.value = false;
}
return { data: data.value, error: error.value, stale: stale.value };
};
return { data, loading, error, stale, fetchPageData };
}

View File

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,102 @@
/**
* Client-side QRPH / EMV QRCPS utilities.
*
* All operations run in the browser — no external API calls.
*
* Exports:
* parseTlv(str) → { tag: value, … }
* buildTlv(fields) → EMV TLV string (sorted, CRC appended)
* injectAmount(qrphStr, amount) → new QRPH string with amount + fresh CRC
* generateQrDataUrl(text, opts) → Promise<dataURL> via qrcode library
*/
// ── CRC-16 / CCITT-FALSE (polynomial 0x1021, seed 0xFFFF) ──────────────────
function crc16(data) {
let crc = 0xFFFF;
for (let i = 0; i < data.length; i++) {
crc ^= data.charCodeAt(i) << 8;
for (let j = 0; j < 8; j++) {
crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xFFFF : (crc << 1) & 0xFFFF;
}
}
return crc.toString(16).toUpperCase().padStart(4, '0');
}
// ── TLV parser ──────────────────────────────────────────────────────────────
export function parseTlv(data) {
const fields = {};
let pos = 0;
while (pos + 4 <= data.length) {
const tag = data.substring(pos, pos + 2);
const len = parseInt(data.substring(pos + 2, pos + 4), 10);
const val = data.substring(pos + 4, pos + 4 + len);
fields[tag] = val;
pos += 4 + len;
if (tag === '63') break;
}
return fields;
}
// ── TLV builder ─────────────────────────────────────────────────────────────
// Reassembles fields in ascending tag order, appends a fresh CRC-16.
function buildTlv(fields) {
// Sort numerically, excluding CRC (will be appended fresh)
const sorted = Object.keys(fields)
.filter(t => t !== '63')
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10));
let body = '';
for (const tag of sorted) {
const val = fields[tag];
const lpad = String(val.length).padStart(2, '0');
body += tag + lpad + val;
}
// Append CRC header then compute over the full string including header
const withHeader = body + '6304';
return withHeader + crc16(withHeader);
}
// ── Amount injector ─────────────────────────────────────────────────────────
/**
* Returns a new QRPH string with field 54 (Transaction Amount) set to
* the given amount and a freshly computed CRC.
*
* @param {string} qrphStr Base QRPH string (stored static QR)
* @param {number} amount PHP amount, e.g. 500
* @returns {string} Modified QRPH string
*/
export function injectAmount(qrphStr, amount) {
const fields = parseTlv(qrphStr);
if (!amount || amount <= 0) {
// Remove amount field and rebuild
delete fields['54'];
} else {
// EMV spec: amount as decimal string, e.g. "500.00"
fields['54'] = parseFloat(amount).toFixed(2);
}
return buildTlv(fields);
}
// ── QR image renderer ───────────────────────────────────────────────────────
/**
* Renders a string as a QR code and returns a data URL (PNG).
*
* @param {string} text
* @param {object} opts qrcode options (width, margin, color…)
* @returns {Promise<string>} data URL
*/
export async function generateQrDataUrl(text, opts = {}) {
const QRCode = (await import('qrcode')).default;
return QRCode.toDataURL(text, {
width: opts.width ?? 260,
margin: opts.margin ?? 2,
errorCorrectionLevel: opts.ecl ?? 'M',
color: {
dark: opts.dark ?? '#000000',
light: opts.light ?? '#FFFFFF',
},
});
}

View File

@@ -0,0 +1,123 @@
// resources/js/composables/useSyncData.js
import { onMounted, onUnmounted } from 'vue';
import { useSyncStore } from '../stores/syncState.js';
/**
* Composable to handle real-time data synchronization using SSE or polling.
*/
export function useSyncData() {
const syncStore = useSyncStore();
let eventSource = null;
const intervals = {};
/**
* Start Server-Sent Events listener.
* @param {string} endpoint
*/
const startSSE = (endpoint = '/api/sync/events') => {
if (typeof EventSource === 'undefined') {
console.warn('[Sync] EventSource not supported by browser.');
return false;
}
if (eventSource) eventSource.close();
eventSource = new EventSource(endpoint);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleSyncMessage(data);
} catch (e) {
console.error('[Sync] Failed to parse SSE message:', e);
}
};
eventSource.onerror = (err) => {
console.error('[Sync] EventSource connection error, closing.');
eventSource.close();
eventSource = null;
};
return true;
};
/**
* Handle incoming synchronization messages.
* @param {object} payload
*/
const handleSyncMessage = (payload) => {
const { type, key, data } = payload;
syncStore.updateStatus(key || type, 'synced');
// Notify Pinia or other stores about updates
// For example:
// switch (type) {
// case 'user_update':
// useUserStore().fetchCurrentUser();
// break;
// }
};
/**
* Start interval-based polling as a fallback or for specific keys.
* @param {string} key
* @param {string} url
* @param {number} intervalMs
* @param {string} method
* @param {object|null} data
*/
const startPolling = (key, url, intervalMs = 60000, method = 'GET', data = null) => {
if (intervals[key]) return;
const pollFunc = async () => {
try {
syncStore.updateStatus(key, 'syncing');
const options = {
method: method,
headers: { 'Accept': 'application/json' }
};
if (data && (method === 'POST' || method === 'PUT')) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
if (!response.ok) throw new Error(`Poll failed: ${response.status}`);
const result = await response.json();
handleSyncMessage({ type: 'poll_result', key, data: result });
} catch (err) {
syncStore.setError(key, err.message);
}
};
// Run immediately then start interval
pollFunc();
intervals[key] = setInterval(pollFunc, intervalMs);
};
/**
* Stop polling for a specific key.
* @param {string} key
*/
const stopPolling = (key) => {
if (intervals[key]) {
clearInterval(intervals[key]);
delete intervals[key];
}
};
// Cleanup on unmount
onUnmounted(() => {
if (eventSource) eventSource.close();
Object.values(intervals).forEach(clearInterval);
});
return {
startSSE,
startPolling,
stopPolling
};
}

View File

@@ -0,0 +1,95 @@
import { ref, onMounted } from 'vue';
import axios from 'axios';
export function useSystemSettings() {
const settings = ref({});
const groupedSettings = ref({});
const isLoading = ref(false);
const error = ref(null);
/**
* Fetch settings (admin version).
*/
const fetchAdminSettings = async () => {
isLoading.value = true;
try {
const response = await axios.post('/admin/ultimate/system-settings');
if (response.data.success) {
groupedSettings.value = response.data.data;
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to fetch settings';
} finally {
isLoading.value = false;
}
};
/**
* Fetch public settings.
*/
const fetchPublicSettings = async () => {
isLoading.value = true;
try {
const response = await axios.get('/api/public/system-settings');
if (response.data.success) {
settings.value = response.data.data;
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to fetch public settings';
} finally {
isLoading.value = false;
}
};
/**
* Update settings.
*/
const updateSettings = async (settingsToUpdate) => {
isLoading.value = true;
try {
const response = await axios.post('/admin/ultimate/system-settings/update', {
settings: settingsToUpdate
});
return response.data;
} catch (err) {
error.value = err.response?.data?.message || 'Failed to update settings';
throw err;
} finally {
isLoading.value = false;
}
};
/**
* Upload logo.
*/
const uploadLogo = async (file) => {
const formData = new FormData();
formData.append('logo', file);
isLoading.value = true;
try {
const response = await axios.post('/admin/ultimate/system-settings/logo', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data;
} catch (err) {
error.value = err.response?.data?.message || 'Failed to upload logo';
throw err;
} finally {
isLoading.value = false;
}
};
return {
settings,
groupedSettings,
isLoading,
error,
fetchAdminSettings,
fetchPublicSettings,
updateSettings,
uploadLogo
};
}

View File

@@ -0,0 +1,204 @@
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

@@ -0,0 +1,153 @@
// resources/js/composables/useUrlArgument.js
// Utility for parsing URL argument format: /page-name--hHASHKEY (no colon) or /page-name--e:PAYLOAD
/**
* Parse URL path to extract page name, hashkey, and payload
* @param {string} urlPath - The full URL path (e.g., "/edituser--h:HASHKEY")
* @returns {object} - { slug, type, hashkey, payload }
*/
export function parseUrlArgument(urlPath) {
if (!urlPath) return null;
const result = {
slug: '', // page name without arguments
type: null, // 'hash' or 'payload'
hashkey: null,
payload: null
};
try {
// Extract the path from URL (remove query string)
let path = urlPath;
if (typeof window !== 'undefined') {
const url = new URL(urlPath);
path = url.pathname || urlPath;
}
// Remove leading slash and normalize
path = path.replace(/^\/+/, '');
// Check for --h: (hashkey) or --e: (payload) patterns
// Hash format: --h:BASE64
// Payload format: --e:BASE64
const hashMatch = path.match(/^(.*?)--h:(.*)$/);
const payloadMatch = path.match(/^(.*?)--e:(.*)$/);
if (hashMatch) {
result.slug = hashMatch[1];
result.type = 'hash';
result.hashkey = decodeHashArgument('h:' + hashMatch[2]);
return result;
}
if (payloadMatch) {
result.slug = payloadMatch[1];
result.type = 'payload';
result.payload = decodePayloadArgument('e:' + payloadMatch[2]);
return result;
}
// No hash/payload found - just return the slug
result.slug = path;
return result;
} catch (e) {
console.error('[parseUrlArgument] Error parsing URL argument:', e);
return null;
}
}
/**
* Decode a hash argument from URL format
*/
export function decodeHashArgument(encodedValue) {
if (!encodedValue) return null;
// Handle both with and without 'h:' prefix
let base64 = encodedValue;
if (encodedValue.startsWith('h:')) {
base64 = encodedValue.substring(2);
}
try {
// First decode from base64
const decoded = atob(base64);
// Then decode from URI component
return decodeURIComponent(decoded);
} catch (e) {
// If decoding fails, it might be a raw hashkey that wasn't encoded
return encodedValue;
}
}
/**
* Decode a payload argument from URL format
*/
export function decodePayloadArgument(encodedValue) {
if (!encodedValue || !encodedValue.startsWith('e:')) return null;
try {
const base64 = encodedValue.substring(2);
// First decodeURIComponent to convert percent-encoded chars back
let decoded = decodeURIComponent(base64);
// Then decode from base64
const decodedBytes = Uint8Array.from(atob(decoded), c =>
(c.charCodeAt(0) & 0xFF)
).map(c => String.fromCharCode(c)).join('');
return JSON.parse(decodedBytes);
} catch (e) {
console.error('[decodePayloadArgument] Error decoding payload:', e);
return null;
}
}
/**
* Get URL argument type from path
*/
export function getUrlArgumentType(urlPath) {
if (!urlPath) return null;
const path = urlPath.replace(/^\/+/, '');
if (path.match(/^(.*?)--h:.*/)) {
return 'hash';
}
if (path.match(/^(.*?)--e:.*/)) {
return 'payload';
}
return null;
}
/**
* Extract hashkey from URL path
*/
export function extractHashkeyFromUrl(urlPath) {
if (!urlPath) return null;
const path = urlPath.replace(/^\/+/, '');
const match = path.match(/^(.*?)--h:(.*)$/);
if (match) {
return decodeHashArgument('h:' + match[2]);
}
return null;
}
/**
* Extract payload from URL path
*/
export function extractPayloadFromUrl(urlPath) {
if (!urlPath) return null;
const path = urlPath.replace(/^\/+/, '');
const match = path.match(/^(.*?)--e:(.*)$/);
if (match) {
return decodePayloadArgument(match[2]);
}
return null;
}

View File

@@ -0,0 +1,88 @@
// resources/js/composables/useUrlEncoder.js
// Utility for encoding/decoding hashkeys and payloads in URLs
/**
* Encode a hashkey value to URL format: h:HASHKEY
*/
export function encodeHash(hashkey) {
if (!hashkey) return null;
// Base64 encode the hashkey for URL safety
try {
const encoded = btoa(encodeURIComponent(hashkey));
return `h:${encoded}`;
} catch (e) {
console.error('[encodeHash] Error encoding hash:', e);
return null;
}
}
/**
* Encode a payload object to URL format: e:BASE64_JSON
*/
export function encodePayload(payload) {
if (!payload) return null;
try {
const json = JSON.stringify(payload);
const encoded = btoa(encodeURIComponent(json));
return `e:${encoded}`;
} catch (e) {
console.error('[encodePayload] Error encoding payload:', e);
return null;
}
}
/**
* Decode a hashkey from URL format
*/
export function decodeHash(encodedValue) {
if (!encodedValue || !encodedValue.startsWith('h:')) return null;
const parts = encodedValue.split(':');
if (parts.length < 2) return null;
try {
// Remove the 'h:' prefix and decode
const base64 = encodedValue.substring(2);
// First decode from base64, then decode URI components
const decoded = atob(base64);
return decodeURIComponent(decoded);
} catch (e) {
console.error('[decodeHash] Error decoding hash:', e);
return null;
}
}
/**
* Decode a payload from URL format
*/
export function decodePayload(encodedValue) {
if (!encodedValue || !encodedValue.startsWith('e:')) return null;
const parts = encodedValue.split(':');
if (parts.length < 2) return null;
try {
// Remove the 'e:' prefix and decode
const base64 = encodedValue.substring(2);
// First decode from base64, then parse JSON
const decodedJson = atob(base64);
return JSON.parse(decodedJson);
} catch (e) {
console.error('[decodePayload] Error decoding payload:', e);
return null;
}
}
/**
* Check if a value is a hashkey encoding
*/
export function isHashKeyFormat(value) {
return typeof value === 'string' && value.startsWith('h:');
}
/**
* Check if a value is a payload encoding
*/
export function isPayloadFormat(value) {
return typeof value === 'string' && value.startsWith('e:');
}

View File

@@ -0,0 +1,131 @@
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

@@ -0,0 +1,116 @@
import { ref, computed } from 'vue';
import axios from 'axios';
import { useUserStore } from '../stores/user';
/**
* Composable for managing current user's notes
*
* Backend endpoints:
* - GET /user/note/content - Get current user's notes
* - GET /user/note/dismiss - Clear (delete) current user's notes
*/
export function useUserNotes() {
const userStore = useUserStore();
const notes = computed(() => userStore.notes);
const loading = ref(false);
const error = ref(null);
/**
* Fetch the current user's notes from the backend
*/
const fetchNotes = async () => {
if (!axios) return;
// Guard: only fetch if the user is authenticated
if (!userStore.isLoggedIn) return;
loading.value = true;
error.value = null;
try {
const response = await axios.get('/user/note/content', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
// Validate response data - if it's HTML, we likely got redirected to login
const data = response.data;
if (typeof data === 'string' && (data.trim().startsWith('<html') || data.trim().startsWith('<!DOCTYPE'))) {
console.warn('Received HTML response for user notes, ignoring (session likely expired).');
// If it's HTML, we definitely don't want to display it as a note
userStore.setNotes('');
return;
}
if (data !== null && data !== false && data !== '') {
userStore.setNotes(data);
} else {
userStore.setNotes('');
}
} catch (err) {
console.error('Error fetching user notes:', err);
error.value = 'Failed to load notes.';
userStore.setNotes('');
} finally {
loading.value = false;
}
};
/**
* Clear/dismiss the current user's notes
*/
const dismissNotes = async () => {
if (!axios) return;
try {
const response = await axios.get('/user/note/dismiss', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
// Validate response - if it's HTML, the session is likely gone
const data = response.data;
if (typeof data === 'string' && (data.trim().startsWith('<html') || data.trim().startsWith('<!DOCTYPE'))) {
console.warn('Received HTML response for dismiss notes, clearing local notes anyway.');
// Clear local since we know something is wrong with the session
userStore.clearNotes();
return true; // Return true so the UI updates
}
if (data === true || (response.status >= 200 && response.status < 300)) {
userStore.clearNotes();
error.value = null;
return true;
}
return false;
} catch (err) {
console.error('Error dismissing user notes:', err);
error.value = 'Failed to dismiss notes.';
// Maybe clear local anyway if it fails?
userStore.clearNotes();
return true;
}
};
/**
* Check if notes exist
*/
const hasNotes = () => {
return !!notes.value && notes.value.length > 0;
};
/**
* Get the notes content
* @returns {string|null} The notes content or null if not loaded
*/
const getNotesContent = () => {
return notes.value;
};
return {
notes,
loading,
error,
fetchNotes,
dismissNotes,
hasNotes,
getNotesContent
};
}

View File

@@ -0,0 +1,44 @@
import { ref, onMounted } from 'vue';
import axios from 'axios';
export function useUserSettings() {
const settings = ref({});
const isLoading = ref(false);
const fetchSettings = async () => {
isLoading.value = true;
try {
const response = await axios.post('/UserSettings/Get');
settings.value = response.data || {};
} catch (error) {
console.error('Failed to fetch user settings:', error);
} finally {
isLoading.value = false;
}
};
const updateSetting = async (key, value) => {
settings.value[key] = value;
try {
await axios.post('/UserSettings/Update', { [key]: value });
} catch (error) {
console.error(`Failed to update setting ${key}:`, error);
}
};
const getSetting = (key, defaultValue = null) => {
return settings.value[key] !== undefined ? settings.value[key] : defaultValue;
};
onMounted(() => {
fetchSettings();
});
return {
settings,
isLoading,
fetchSettings,
updateSetting,
getSetting
};
}