Initial commit
This commit is contained in:
260
resources/js/account-balances-chart.js
Normal file
260
resources/js/account-balances-chart.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
window.initAccountBalancesChart = function(timelineData, decimalFormat) {
|
||||
const chartCanvas = document.getElementById('accountBalancesChart');
|
||||
if (!chartCanvas || !timelineData || timelineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (chartCanvas.chart) {
|
||||
chartCanvas.chart.destroy();
|
||||
}
|
||||
|
||||
// Format minutes as either HH:MM or decimal hours
|
||||
function formatMinutes(minutes) {
|
||||
if (decimalFormat) {
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const decimal = (absMinutes / 60).toFixed(2).replace('.', ',');
|
||||
return (isNegative ? '-' : '') + decimal + ' h.';
|
||||
}
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const wholeHours = Math.floor(absMinutes / 60);
|
||||
const restMinutes = String(absMinutes % 60).padStart(2, '0');
|
||||
return 'H ' + (isNegative ? '-' : '') + wholeHours + ':' + restMinutes;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare datasets for each account
|
||||
const datasets = [];
|
||||
const allAccounts = new Map();
|
||||
|
||||
timelineData.forEach(month => {
|
||||
if (month.accounts && Array.isArray(month.accounts)) {
|
||||
month.accounts.forEach(account => {
|
||||
if (!allAccounts.has(account.account_id)) {
|
||||
allAccounts.set(account.account_id, {
|
||||
name: account.account_name,
|
||||
id: account.account_id
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
|
||||
let colorIndex = 0;
|
||||
|
||||
allAccounts.forEach((accountInfo, accountId) => {
|
||||
const accountData = timelineData.map(month => {
|
||||
const accountBalance = month.accounts?.find(acc => acc.account_id === accountId);
|
||||
const minutes = accountBalance ? parseInt(accountBalance.balance) : 0;
|
||||
// Convert minutes to decimal hours for chart display
|
||||
return minutes / 60;
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: accountInfo.name,
|
||||
data: accountData,
|
||||
borderColor: colors[colorIndex % colors.length],
|
||||
backgroundColor: colors[colorIndex % colors.length] + '33', // 0.2 transparency (33 in hex = 20% opacity)
|
||||
borderWidth: 3,
|
||||
tension: 0.4, // More curved line
|
||||
fill: true, // Fill below the line
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: colors[colorIndex % colors.length],
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
});
|
||||
|
||||
colorIndex++;
|
||||
});
|
||||
|
||||
if (datasets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chartLabels = timelineData.map(item => item.label);
|
||||
const chartType = chartLabels.length === 1 ? 'bar' : 'line';
|
||||
|
||||
// Get translated labels from the first data point
|
||||
const translations = timelineData[0]?.translations || {
|
||||
period: 'Period',
|
||||
balance: 'Balance'
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: '#1F2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const minutes = Math.round(context.parsed.y * 60);
|
||||
return context.dataset.label + ': ' + formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.period,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.balance,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
callback: function(value) {
|
||||
const minutes = Math.round(value * 60);
|
||||
return formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartCanvas.chart = new Chart(ctx, config);
|
||||
|
||||
// Store chart instance globally for PDF export
|
||||
window.accountBalancesChart = chartCanvas.chart;
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// EXACT COPY of return ratio chart initialization pattern
|
||||
function initializeChart() {
|
||||
// Find chart container
|
||||
const chartContainer = document.querySelector('[data-balance-chart-data]');
|
||||
if (!chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get chart data
|
||||
const timelineDataAttr = chartContainer.getAttribute('data-balance-chart-data');
|
||||
|
||||
if (!timelineDataAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timelineData = JSON.parse(timelineDataAttr);
|
||||
const decimalFormat = chartContainer.getAttribute('data-decimal-format') === '1';
|
||||
|
||||
// Initialize the chart
|
||||
if (typeof window.initAccountBalancesChart === 'function') {
|
||||
window.initAccountBalancesChart(timelineData, decimalFormat);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeChart, 100);
|
||||
});
|
||||
|
||||
// Listen for Livewire updates - debounced to prevent rapid re-initialization
|
||||
document.addEventListener('livewire:init', () => {
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(() => {
|
||||
initializeChart();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to export Account Balances chart as base64 image for PDF
|
||||
window.exportAccountBalancesChartForPdf = async function() {
|
||||
|
||||
if (!window.accountBalancesChart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalChart = window.accountBalancesChart;
|
||||
|
||||
// Render into an offscreen canvas to avoid touching the live DOM canvas
|
||||
// (which can be detached by Livewire morphs mid-export)
|
||||
const pdfWidth = 900;
|
||||
const pdfHeight = 400;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = pdfWidth;
|
||||
offscreen.height = pdfHeight;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
|
||||
// Clone config from existing chart
|
||||
const offscreenChart = new Chart(ctx, {
|
||||
type: originalChart.config.type,
|
||||
data: JSON.parse(JSON.stringify(originalChart.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(originalChart.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const chartImage = offscreen.toDataURL('image/png', 1.0);
|
||||
|
||||
offscreenChart.destroy();
|
||||
|
||||
return chartImage;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
123
resources/js/app.js
Normal file
123
resources/js/app.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// SNAPSHOT
|
||||
|
||||
import './bootstrap';
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import focus from "@alpinejs/focus";
|
||||
import tagifyMin from "@yaireo/tagify";
|
||||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import './presence-tracker.js';
|
||||
import './return-ratio-chart.js';
|
||||
import './account-balances-chart.js';
|
||||
|
||||
|
||||
Alpine.plugin(focus);
|
||||
|
||||
// Register Chart.js globally
|
||||
Chart.register(...registerables);
|
||||
window.Chart = Chart;
|
||||
|
||||
window.createPopper = createPopper;
|
||||
window.Tagify = tagifyMin; // Needs to be loaded after Alpine
|
||||
|
||||
// Lazy-load JSZip (used by backup restore chunked upload)
|
||||
window.loadJSZip = () => import('jszip').then(m => m.default);
|
||||
|
||||
// Alpine component for the call tag picker (livewire:calls.call-skill-input)
|
||||
// Registered via Alpine.data() so Alpine can resolve it when Livewire morphs in new DOM nodes.
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('callTagPicker', () => ({
|
||||
callTagify: null,
|
||||
init() {
|
||||
const input = this.$refs.callTagsInput;
|
||||
const suggestions = JSON.parse(input.dataset.suggestions || '[]');
|
||||
|
||||
this.callTagify = new Tagify(input, {
|
||||
maxTags: 1,
|
||||
whitelist: suggestions,
|
||||
enforceWhiteList: false,
|
||||
backspace: false,
|
||||
editTags: false,
|
||||
addTagOn: ['enter', 'tab'],
|
||||
addTagOnBlur: false,
|
||||
dropdown: {
|
||||
maxItems: 10,
|
||||
enabled: 2,
|
||||
closeOnSelect: true,
|
||||
highlightFirst: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-populate with initial value from server (e.g. edit modal)
|
||||
const initialTags = this.$wire.get('tagsArray');
|
||||
if (initialTags && initialTags !== '[]') {
|
||||
this.callTagify.loadOriginalValues(initialTags);
|
||||
}
|
||||
|
||||
this.callTagify.on('change', () => {
|
||||
// Read from the actual input element — e.target can be a #text node in Tagify
|
||||
const rawValue = this.$refs.callTagsInput.value;
|
||||
const tags = JSON.parse(rawValue || '[]');
|
||||
if (tags.length === 0) {
|
||||
this.$wire.set('tagsArray', '[]', false);
|
||||
this.$wire.call('notifyTagCleared');
|
||||
return;
|
||||
}
|
||||
const tag = tags[0];
|
||||
if (tag.tag_id) {
|
||||
// Known tag — sync and notify parent
|
||||
this.$wire.set('tagsArray', rawValue, false);
|
||||
this.$wire.call('notifyTagSelected', tag.tag_id);
|
||||
} else {
|
||||
// Unknown tag — open creation modal (no set() to avoid re-render race)
|
||||
this.$wire.call('openNewTagModal', tag.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Server asks us to remove the pending unconfirmed tag (cancel modal)
|
||||
window.addEventListener('removeLastCallTag', () => {
|
||||
if (this.callTagify && this.callTagify.value.length > 0) {
|
||||
this.callTagify.removeTag(
|
||||
this.callTagify.value[this.callTagify.value.length - 1].value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Server pushes a new tagsArray after createTag — reload Tagify with colored badge
|
||||
Livewire.on('callTagifyReload', (data) => {
|
||||
this.callTagify.loadOriginalValues(data.tagsArray);
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
window.Pusher = Pusher;
|
||||
|
||||
// Get the current locale from the URL (e.g., /en/, /nl/, etc.)
|
||||
const getLocalePrefix = () => {
|
||||
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
||||
const locales = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
if (pathParts.length > 0 && locales.includes(pathParts[0])) {
|
||||
return `/${pathParts[0]}`;
|
||||
}
|
||||
return '/en'; // Default to English if no locale found
|
||||
};
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
wsPath: import.meta.env.VITE_REVERB_PATH ?? "/",
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: `${getLocalePrefix()}/broadcasting/auth`,
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
35
resources/js/bootstrap.js
vendored
Normal file
35
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
import _ from 'lodash';
|
||||
window._ = _;
|
||||
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
|
||||
// Reload page when active profile timeouts. Important for Banks ans Admin profiles.
|
||||
if (window.axios) {
|
||||
window.axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
// Check specifically for the 419 status code
|
||||
if (error.response && error.response.status === 419) {
|
||||
if (error.response.data && error.response.data.action === 'redirect' && error.response.data.redirect_url) {
|
||||
window.location.href = error.response.data.redirect_url; // Use the URL from payload
|
||||
} else {
|
||||
window.location.href = '/login'; // Redirect to generic login as fallback
|
||||
}
|
||||
}
|
||||
// Important: Reject the promise so other error handlers can process it
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
37
resources/js/echo.js
Normal file
37
resources/js/echo.js
Normal file
@@ -0,0 +1,37 @@
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allows your team to easily build robust real-time web applications.
|
||||
*/
|
||||
|
||||
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
window.Pusher = require('pusher-js');
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
// Get current locale from URL path (e.g., /en/, /nl/, etc.)
|
||||
const locale = window.location.pathname.split('/')[1] || 'en';
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
wsPath: import.meta.env.VITE_REVERB_PATH ?? "/",
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: `/${locale}/broadcasting/auth`,
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
15
resources/js/joypixels.min.js
vendored
Normal file
15
resources/js/joypixels.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
195
resources/js/presence-tracker.js
Normal file
195
resources/js/presence-tracker.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// Enhanced presence tracker with AJAX fallback
|
||||
class PresenceTracker {
|
||||
constructor() {
|
||||
this.heartbeatInterval = null;
|
||||
this.offlineTimeout = null;
|
||||
this.lastActivity = Date.now();
|
||||
this.isOnline = true;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupActivityTracking();
|
||||
this.setupVisibilityHandling();
|
||||
this.setupBeforeUnload();
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
setupActivityTracking() {
|
||||
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
||||
|
||||
events.forEach(event => {
|
||||
document.addEventListener(event, () => {
|
||||
this.updateActivity();
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
setupVisibilityHandling() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handleUserAway();
|
||||
} else {
|
||||
this.handleUserBack();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupBeforeUnload() {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.setOffline();
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
this.setOffline();
|
||||
});
|
||||
}
|
||||
|
||||
updateActivity() {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastActivity > 15000) { // 15 seconds
|
||||
this.lastActivity = now;
|
||||
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
// Set offline timeout for 5 minutes of inactivity
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 300000);
|
||||
|
||||
// Update Livewire components
|
||||
this.notifyLivewireComponents('handleUserActivity');
|
||||
}
|
||||
}
|
||||
|
||||
handleUserAway() {
|
||||
// Set shorter offline timeout when tab is hidden
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 30000); // 30 seconds when tab is hidden
|
||||
}
|
||||
|
||||
handleUserBack() {
|
||||
this.lastActivity = Date.now();
|
||||
this.heartbeat(); // Immediate heartbeat when coming back
|
||||
|
||||
// Reset normal offline timeout
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 300000); // 5 minutes
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
// Send heartbeat every 30 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (!document.hidden && this.isOnline) {
|
||||
this.heartbeat();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
heartbeat() {
|
||||
const guards = this.getActiveGuards();
|
||||
|
||||
guards.forEach(guard => {
|
||||
fetch('/presence/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: JSON.stringify({ guard: guard })
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
this.isOnline = true;
|
||||
this.notifyLivewireComponents('handleUserActivity');
|
||||
}
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
setOffline() {
|
||||
if (!this.isOnline) return; // Already offline
|
||||
|
||||
const guards = this.getActiveGuards();
|
||||
|
||||
guards.forEach(guard => {
|
||||
// Use sendBeacon for reliability during page unload
|
||||
const data = JSON.stringify({ guard: guard });
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/presence/offline', data);
|
||||
} else {
|
||||
fetch('/presence/offline', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: data
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
this.isOnline = false;
|
||||
this.notifyLivewireComponents('handleUserOffline');
|
||||
}
|
||||
|
||||
getActiveGuards() {
|
||||
const guards = new Set();
|
||||
document.querySelectorAll('.user-presence-container').forEach(container => {
|
||||
const guard = container.getAttribute('data-guard') || 'web';
|
||||
guards.add(guard);
|
||||
});
|
||||
return Array.from(guards);
|
||||
}
|
||||
|
||||
notifyLivewireComponents(method) {
|
||||
document.querySelectorAll('[wire\\:id]').forEach(component => {
|
||||
const componentId = component.getAttribute('wire:id');
|
||||
if (componentId && component.classList.contains('user-presence-container')) {
|
||||
try {
|
||||
Livewire.find(componentId).call(method);
|
||||
} catch (e) {
|
||||
// component not available
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
this.setOffline();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize presence tracker
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.presenceTracker = new PresenceTracker();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.presenceTracker) {
|
||||
window.presenceTracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
export default PresenceTracker;
|
||||
6
resources/js/quill.js
Normal file
6
resources/js/quill.js
Normal file
@@ -0,0 +1,6 @@
|
||||
// Import Quill editor
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
|
||||
// Make Quill available globally for Alpine.js components
|
||||
window.Quill = Quill;
|
||||
411
resources/js/return-ratio-chart.js
Normal file
411
resources/js/return-ratio-chart.js
Normal file
@@ -0,0 +1,411 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
window.initReturnRatioChart = function(timelineData, trendData = []) {
|
||||
const chartCanvas = document.getElementById('returnRatioChart');
|
||||
if (!chartCanvas || !timelineData || timelineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get data range for better debugging
|
||||
const values = timelineData.map(item => item.return_ratio);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (chartCanvas.chart) {
|
||||
chartCanvas.chart.destroy();
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Prepare datasets - trend line first to ensure it renders behind the main line
|
||||
const datasets = [];
|
||||
|
||||
// Add trend line first (renders behind)
|
||||
if (trendData && trendData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Trend Line',
|
||||
data: trendData.map(item => item.trend_value),
|
||||
borderColor: '#DC2626', // red-600 for better visibility
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 3, // Increased width for better visibility
|
||||
borderDash: [8, 4], // Adjusted dash pattern for better visibility
|
||||
tension: 0, // Linear regression line (straight)
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
order: 1, // Render behind main line
|
||||
});
|
||||
}
|
||||
|
||||
// Add main return ratio line (renders on top)
|
||||
datasets.push({
|
||||
label: 'Reciprocity Rate %',
|
||||
data: timelineData.map(item => item.return_ratio),
|
||||
borderColor: '#000', // black
|
||||
backgroundColor: 'rgba(107, 114, 128, 0.1)', // Reduced opacity for less interference
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: '#000', // black
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
order: 0, // Render on top
|
||||
});
|
||||
|
||||
// Get translated labels from the first data point
|
||||
const translations = timelineData[0]?.translations || {
|
||||
period: 'Period',
|
||||
return_ratio: 'Reciprocity Rate'
|
||||
};
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timelineData.map(item => item.label),
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'end',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: '#1F2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: function(context) {
|
||||
const tooltip = context.tooltip;
|
||||
let tooltipEl = document.getElementById('custom-tooltip');
|
||||
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'custom-tooltip';
|
||||
tooltipEl.style.position = 'absolute';
|
||||
tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)';
|
||||
tooltipEl.style.color = 'white';
|
||||
tooltipEl.style.border = '2px solid white';
|
||||
tooltipEl.style.borderRadius = '8px';
|
||||
tooltipEl.style.pointerEvents = 'none';
|
||||
tooltipEl.style.transform = 'translate(-50%, 0)';
|
||||
tooltipEl.style.transition = 'all .1s ease';
|
||||
tooltipEl.style.padding = '10px';
|
||||
tooltipEl.style.fontSize = '12px';
|
||||
tooltipEl.style.fontFamily = 'Arial, sans-serif';
|
||||
tooltipEl.style.zIndex = '9999';
|
||||
tooltipEl.style.boxShadow = 'none';
|
||||
tooltipEl.style.outline = 'none';
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (tooltip.body) {
|
||||
const titleLines = tooltip.title || [];
|
||||
const bodyLines = tooltip.body.map(b => b.lines);
|
||||
|
||||
let innerHtml = '';
|
||||
|
||||
// Add title
|
||||
titleLines.forEach(function(title) {
|
||||
innerHtml += '<div style="font-weight: bold; margin-bottom: 5px;">' + title + '</div>';
|
||||
});
|
||||
|
||||
// Add body with color indicators
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = tooltip.labelColors[i];
|
||||
const colorBox = '<span style="display: inline-block; width: 12px; height: 12px; background-color: ' +
|
||||
colors.backgroundColor + '; border: 1px solid ' + colors.borderColor +
|
||||
'; margin-right: 5px; vertical-align: middle;"></span>';
|
||||
|
||||
// Fix the label based on dataset order (trend first, return ratio second)
|
||||
let label = body[0];
|
||||
if (i === 0) {
|
||||
// First dataset = Trend Line (red)
|
||||
label = 'Trend: ' + label.split(': ')[1];
|
||||
} else {
|
||||
// Second dataset = Reciprocity Rate (gray)
|
||||
label = 'Reciprocity Rate: ' + label.split(': ')[1];
|
||||
}
|
||||
|
||||
innerHtml += '<div style="margin: 2px 0;">' + colorBox + label + '</div>';
|
||||
});
|
||||
|
||||
tooltipEl.innerHTML = innerHtml;
|
||||
}
|
||||
|
||||
const position = context.chart.canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.left = position.left + window.pageXOffset + tooltip.caretX + 'px';
|
||||
tooltipEl.style.top = position.top + window.pageYOffset + tooltip.caretY + 'px';
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.period,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.return_ratio,
|
||||
color: '#6B7280'
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
callback: function(value) {
|
||||
return Math.round(value) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Store chart reference for cleanup
|
||||
chartCanvas.chart = chart;
|
||||
|
||||
// Store chart instance globally for PDF export
|
||||
window.returnRatioChart = chart;
|
||||
|
||||
// Set canvas height
|
||||
chartCanvas.style.height = '300px';
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingIndicator = document.getElementById('chartLoadingIndicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Livewire integration for chart initialization and PDF handling
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('openPdf', (url) => {
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
// Function to initialize chart
|
||||
function initializeChart() {
|
||||
|
||||
// Find chart container
|
||||
const chartContainer = document.querySelector('[data-chart-data]');
|
||||
if (!chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get chart data
|
||||
const timelineDataAttr = chartContainer.getAttribute('data-chart-data');
|
||||
const trendDataAttr = chartContainer.getAttribute('data-trend-data');
|
||||
|
||||
if (!timelineDataAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timelineData = JSON.parse(timelineDataAttr);
|
||||
const trendData = trendDataAttr ? JSON.parse(trendDataAttr) : [];
|
||||
|
||||
// Initialize the chart
|
||||
if (typeof window.initReturnRatioChart === 'function') {
|
||||
window.initReturnRatioChart(timelineData, trendData);
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeChart, 100);
|
||||
});
|
||||
|
||||
// Listen for Livewire updates - debounced
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(() => {
|
||||
initializeChart();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to export Return Ratio chart as base64 image for PDF
|
||||
window.exportReturnRatioChartForPdf = async function() {
|
||||
|
||||
if (!window.returnRatioChart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalChart = window.returnRatioChart;
|
||||
|
||||
// Render into an offscreen canvas to avoid touching the live DOM canvas
|
||||
const pdfWidth = 900;
|
||||
const pdfHeight = 400;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = pdfWidth;
|
||||
offscreen.height = pdfHeight;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
|
||||
const offscreenChart = new Chart(ctx, {
|
||||
type: originalChart.config.type,
|
||||
data: JSON.parse(JSON.stringify(originalChart.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(originalChart.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const chartImage = offscreen.toDataURL('image/png', 1.0);
|
||||
|
||||
offscreenChart.destroy();
|
||||
|
||||
return chartImage;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Find the SingleReport Livewire component by looking for its unique DOM marker
|
||||
window.findSingleReportComponent = function() {
|
||||
if (!window.Livewire) return null;
|
||||
// Primary: use the dedicated id marker on the single-report root div
|
||||
const marker = document.getElementById('single-report-component');
|
||||
if (marker) {
|
||||
const wireElement = marker.closest('[wire\\:id]');
|
||||
if (wireElement) {
|
||||
return window.Livewire.find(wireElement.getAttribute('wire:id'));
|
||||
}
|
||||
}
|
||||
// Fallback: scan for unique child elements
|
||||
const elements = document.querySelectorAll('[wire\\:id]');
|
||||
for (let element of elements) {
|
||||
if (
|
||||
element.querySelector('[data-chart-data]') ||
|
||||
element.querySelector('[data-balance-chart-data]') ||
|
||||
element.querySelector('#returnRatioChart') ||
|
||||
element.querySelector('#accountBalancesChart') ||
|
||||
element.querySelector('#account-balances') ||
|
||||
element.querySelector('#transaction-types')
|
||||
) {
|
||||
return window.Livewire.find(element.getAttribute('wire:id'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Function to export both charts and send to Livewire for PDF generation
|
||||
window.exportPdfWithChart = async function() {
|
||||
|
||||
// Export Return Ratio Chart
|
||||
const returnRatioChartImage = await window.exportReturnRatioChartForPdf();
|
||||
|
||||
// Export Account Balances Chart (if available)
|
||||
let accountBalancesChartImage = null;
|
||||
if (window.accountBalancesChart) {
|
||||
accountBalancesChartImage = await window.exportAccountBalancesChartForPdf();
|
||||
}
|
||||
|
||||
// If neither chart is available, fall back to regular PDF export
|
||||
if (!returnRatioChartImage && !accountBalancesChartImage) {
|
||||
if (window.Livewire) {
|
||||
const component = window.findSingleReportComponent();
|
||||
if (component) {
|
||||
component.call('exportPdf');
|
||||
} else {
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Read date range and decimal format from DOM
|
||||
const chartContainer = document.querySelector('[data-balance-chart-data]') || document.querySelector('[data-chart-data]');
|
||||
let fromDate = null;
|
||||
let toDate = null;
|
||||
let decimalFormat = false;
|
||||
|
||||
if (chartContainer) {
|
||||
const dateRange = chartContainer.getAttribute('data-date-range');
|
||||
if (dateRange) {
|
||||
const dates = dateRange.split(' to ');
|
||||
if (dates.length === 2) {
|
||||
fromDate = dates[0].trim();
|
||||
toDate = dates[1].trim();
|
||||
}
|
||||
}
|
||||
decimalFormat = chartContainer.getAttribute('data-decimal-format') === '1';
|
||||
}
|
||||
|
||||
if (window.Livewire) {
|
||||
try {
|
||||
const component = window.findSingleReportComponent();
|
||||
if (!component) {
|
||||
throw new Error('SingleReport Livewire component not found');
|
||||
}
|
||||
await component.call('exportPdfWithCharts', returnRatioChartImage, accountBalancesChartImage, fromDate, toDate, decimalFormat);
|
||||
} catch (error) {
|
||||
try {
|
||||
const comp = window.findSingleReportComponent();
|
||||
if (comp) {
|
||||
await comp.call('exportPdf');
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
103
resources/js/skilltags.js
Normal file
103
resources/js/skilltags.js
Normal file
@@ -0,0 +1,103 @@
|
||||
let tagify;
|
||||
|
||||
window.addEventListener('remove', function () {
|
||||
if (tagify && tagify.value.length > 0) {
|
||||
tagify.removeTag(tagify.value[tagify.value.length - 1].value);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initializeTagify();
|
||||
|
||||
// Listen for custom event to update Tagify
|
||||
window.addEventListener('tagifyChange', function (e) {
|
||||
if (tagify) {
|
||||
tagify.loadOriginalValues(e.detail.tagsArray);
|
||||
}
|
||||
});
|
||||
|
||||
window.Livewire.on('disableSelect', () => {
|
||||
document.getElementById('select-translation').style.opacity = '0.4';
|
||||
document.getElementById('select-translation').style.cursor = 'pointer';
|
||||
document.getElementById('select-translation').style.pointerEvents = 'none';
|
||||
document.getElementById('input-translation').style.opacity = '1';
|
||||
});
|
||||
|
||||
window.Livewire.on('disableInput', () => {
|
||||
document.getElementById('input-translation').style.opacity = '0.4';
|
||||
document.getElementById('select-translation').style.cursor = 'default';
|
||||
document.getElementById('select-translation').style.pointerEvents = 'auto';
|
||||
document.getElementById('select-translation').style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
function initializeTagify() {
|
||||
const input = document.getElementById('tags');
|
||||
|
||||
// Destroy existing Tagify instance if it exists
|
||||
if (tagify) {
|
||||
tagify.destroy();
|
||||
}
|
||||
|
||||
tagify = new Tagify(input, {
|
||||
pattern: /^.{3,80}$/, // max 80 characters, make sure also validation rule in Model is equally set
|
||||
maxTags: 50,
|
||||
autocapitalize: true,
|
||||
id: 'skillTags',
|
||||
whitelist: JSON.parse(input.dataset.suggestions),
|
||||
enforceWhiteList: false,
|
||||
backspace: false,
|
||||
editTags: false,
|
||||
addTagOn: ['blur', 'enter', 'tab'],
|
||||
autoComplete: {
|
||||
rightKey: true,
|
||||
tabKey: true,
|
||||
},
|
||||
dropdown: {
|
||||
classname: 'bg-theme-brand text-white',
|
||||
maxItems: 10, // maximum allowed rendered suggestions
|
||||
classname: 'readonlyMix', // Foreign tags are readonly and have a distinct appearance
|
||||
enabled: 3, // characters typed to show suggestions on focus
|
||||
position: 'text', // place the dropdown near the typed text
|
||||
closeOnSelect: true, // don't hide the dropdown when an item is selected
|
||||
highlightFirst: true, // highlight / suggest best match
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
tagify.on('dblclick', onChange);
|
||||
|
||||
|
||||
tagify.on('focus', () => {
|
||||
Livewire.dispatch('tagifyFocus');
|
||||
});
|
||||
|
||||
|
||||
tagify.on('blur', () => {
|
||||
Livewire.dispatch('tagifyBlur');
|
||||
});
|
||||
|
||||
|
||||
function onChange(e) {
|
||||
const component = Livewire.find(input.closest('[wire\\:id]').getAttribute('wire:id'));
|
||||
component.set('tagsArray', e.target.value);
|
||||
}
|
||||
|
||||
function onLoaded(e) {
|
||||
const tagsInput = document.getElementById('tags');
|
||||
if (tagsInput) {
|
||||
tagsInput.style.display = 'block';
|
||||
}
|
||||
onChange(e);
|
||||
}
|
||||
|
||||
|
||||
function onReloadPage() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// input.addEventListener('change', onChange);
|
||||
window.addEventListener('load', onLoaded);
|
||||
window.addEventListener('reloadPage', onReloadPage);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user