initial: bootstrap from BukidBountyApp base
This commit is contained in:
147
resources/js/composables/Core/useAuth.js
Normal file
147
resources/js/composables/Core/useAuth.js
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
309
resources/js/composables/Core/useModal.js
Normal file
309
resources/js/composables/Core/useModal.js
Normal 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,
|
||||
}
|
||||
}
|
||||
227
resources/js/composables/Core/useNavigate.js
Normal file
227
resources/js/composables/Core/useNavigate.js
Normal 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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
17
resources/js/composables/Core/usePageTitle.js
Normal file
17
resources/js/composables/Core/usePageTitle.js
Normal 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()
|
||||
}
|
||||
}
|
||||
115
resources/js/composables/Core/usePrefetch.js
Normal file
115
resources/js/composables/Core/usePrefetch.js
Normal 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
|
||||
};
|
||||
}
|
||||
360
resources/js/composables/Core/useSessionGuard.js
Normal file
360
resources/js/composables/Core/useSessionGuard.js
Normal 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,
|
||||
};
|
||||
}
|
||||
162
resources/js/composables/Market/usePosSession.js
Normal file
162
resources/js/composables/Market/usePosSession.js
Normal 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
|
||||
};
|
||||
}
|
||||
53
resources/js/composables/useActivity.js
Normal file
53
resources/js/composables/useActivity.js
Normal 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
|
||||
};
|
||||
}
|
||||
130
resources/js/composables/useAnnouncements.js
Normal file
130
resources/js/composables/useAnnouncements.js
Normal 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
|
||||
};
|
||||
}
|
||||
220
resources/js/composables/useChapters.js
Normal file
220
resources/js/composables/useChapters.js
Normal 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,
|
||||
};
|
||||
}
|
||||
74
resources/js/composables/useFileBlobCache.js
Normal file
74
resources/js/composables/useFileBlobCache.js
Normal 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
|
||||
};
|
||||
}
|
||||
112
resources/js/composables/useFileUpload.js
Normal file
112
resources/js/composables/useFileUpload.js
Normal 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
|
||||
};
|
||||
}
|
||||
29
resources/js/composables/useGlobalTransactions.js
Normal file
29
resources/js/composables/useGlobalTransactions.js
Normal 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
|
||||
};
|
||||
}
|
||||
98
resources/js/composables/useHashKeyCache.js
Normal file
98
resources/js/composables/useHashKeyCache.js
Normal 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
|
||||
};
|
||||
}
|
||||
131
resources/js/composables/useOPFS.js
Normal file
131
resources/js/composables/useOPFS.js
Normal 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
|
||||
};
|
||||
}
|
||||
176
resources/js/composables/useOfflineStore.js
Normal file
176
resources/js/composables/useOfflineStore.js
Normal 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
|
||||
};
|
||||
}
|
||||
62
resources/js/composables/usePageData.js
Normal file
62
resources/js/composables/usePageData.js
Normal 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 };
|
||||
}
|
||||
56
resources/js/composables/usePhotoList.js
Normal file
56
resources/js/composables/usePhotoList.js
Normal 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
|
||||
};
|
||||
}
|
||||
102
resources/js/composables/useQrph.js
Normal file
102
resources/js/composables/useQrph.js
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
123
resources/js/composables/useSyncData.js
Normal file
123
resources/js/composables/useSyncData.js
Normal 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
|
||||
};
|
||||
}
|
||||
95
resources/js/composables/useSystemSettings.js
Normal file
95
resources/js/composables/useSystemSettings.js
Normal 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
|
||||
};
|
||||
}
|
||||
204
resources/js/composables/useUltimate.js
Normal file
204
resources/js/composables/useUltimate.js
Normal 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
|
||||
};
|
||||
}
|
||||
153
resources/js/composables/useUrlArgument.js
Normal file
153
resources/js/composables/useUrlArgument.js
Normal 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;
|
||||
}
|
||||
88
resources/js/composables/useUrlEncoder.js
Normal file
88
resources/js/composables/useUrlEncoder.js
Normal 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:');
|
||||
}
|
||||
131
resources/js/composables/useUserAdditionalDetails.js
Normal file
131
resources/js/composables/useUserAdditionalDetails.js
Normal 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
|
||||
};
|
||||
}
|
||||
116
resources/js/composables/useUserNotes.js
Normal file
116
resources/js/composables/useUserNotes.js
Normal 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
|
||||
};
|
||||
}
|
||||
44
resources/js/composables/useUserSettings.js
Normal file
44
resources/js/composables/useUserSettings.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user