1077 lines
23 KiB
Vue

<template>
<div class="status-chart-container">
<!-- Loading Overlay -->
<div v-if="loading" class="loading-overlay">
<div class="spinner"></div>
<div class="loading-text">Loading chart data...</div>
</div>
<!-- Week Navigation -->
<div class="week-navigation" :class="{ disabled: loading }">
<!-- Weekly Filter Toggle -->
<div class="filter-toggle">
<label class="toggle-label">
<input
type="checkbox"
v-model="weeklyFilterEnabled"
@change="handleWeeklyToggle"
class="toggle-checkbox"
/>
<span class="toggle-slider"></span>
<span class="toggle-text">Weekly Filter</span>
</label>
</div>
<!-- Week Navigation Controls -->
<div v-if="weeklyFilterEnabled" class="week-controls">
<button @click="previousWeek" class="nav-button">
<i class="mdi mdi-chevron-left"></i>
</button>
<div class="week-display">
<span>{{ formatWeekRange(currentWeekStart, currentWeekEnd) }}</span>
<button @click="showDatePicker = !showDatePicker" class="date-picker-button">
<i class="mdi mdi-calendar"></i>
</button>
</div>
<button @click="nextWeek" class="nav-button">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<!-- All Time Display -->
<div v-else class="all-time-display">
<span class="all-time-text">All Time Data</span>
</div>
</div>
<!-- Date Picker Overlay -->
<div v-if="showDatePicker && weeklyFilterEnabled" class="date-picker-overlay">
<div class="date-picker-content">
<label>Select Week Starting:</label>
<input
type="date"
v-model="selectedDate"
@change="jumpToWeek"
class="date-input"
/>
<button @click="showDatePicker = false" class="close-button">Close</button>
</div>
</div>
<!-- Category Selector -->
<div
class="category-selector"
v-if="(statusCategories && statusCategories.length > 0) || Array.isArray(statusData)"
>
<label class="selector-label">Select Categories:</label>
<div class="category-multiselect">
<div
v-for="category in getAvailableCategories()"
:key="category.label"
class="category-option"
:class="{ selected: selectedCategories.includes(category.label) }"
@click="toggleCategory(category.label)"
>
<input
type="checkbox"
:checked="selectedCategories.includes(category.label)"
@change="toggleCategory(category.label)"
class="category-checkbox"
/>
<span class="category-label">{{ category.label }}</span>
</div>
</div>
</div>
<!-- View Selector (only show if no categories provided - fallback to old behavior) -->
<div class="view-selector" v-else-if="viewOptions && viewOptions.length > 0">
<select v-model="selectedView" @change="handleViewChange" class="view-dropdown">
<option value="totals">Total Count</option>
<option v-for="option in viewOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Chart Container -->
<div class="chart-wrapper">
<canvas ref="chartCanvas" class="chart-canvas" v-show="!loading"></canvas>
<!-- Center Data Display -->
<div class="center-data" v-if="centerData && !loading">
<div class="center-label">{{ centerData.label }}</div>
<div class="center-value">{{ centerData.value }}</div>
<div class="center-percentage" v-if="centerData.percentage">
{{ centerData.percentage }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed, onUnmounted } from "vue";
import { Chart, registerables } from "chart.js";
// Register Chart.js components
Chart.register(...registerables);
const props = defineProps({
// Simple data structure: { totals: {}, categories: {} }
statusData: {
type: Object,
required: true,
},
// New prop: Array of status categories
statusCategories: {
type: Array,
default: () => [],
},
// View options for dropdowns (fallback for old behavior)
viewOptions: {
type: Array,
default: () => [],
},
// Status configuration (fallback for old behavior)
statusConfig: {
type: Array,
default: () => [
{ key: "Not Started", label: "Not Started", color: "#ef4444", hoverColor: "#dc2626" },
{ key: "In Progress", label: "In Progress", color: "#f59e0b", hoverColor: "#d97706" },
{ key: "Completed", label: "Completed", color: "#10b981", hoverColor: "#059669" },
],
},
// Callback for week changes
onWeekChange: {
type: Function,
required: true,
},
// Loading state
loading: {
type: Boolean,
default: false,
},
});
// Reactive data
const chartCanvas = ref(null);
const chartInstance = ref(null);
const showDatePicker = ref(false);
const selectedDate = ref("");
const weeklyFilterEnabled = ref(true);
const selectedView = ref("totals");
const centerData = ref(null);
const hoveredSegment = ref(null);
const selectedCategories = ref([]);
// Week navigation
const currentWeekStart = ref(new Date());
const currentWeekEnd = ref(new Date());
// Initialize current week to start of week (Sunday)
const initializeCurrentWeek = () => {
const now = new Date();
const dayOfWeek = now.getDay();
const diff = now.getDate() - dayOfWeek;
currentWeekStart.value = new Date(now.setDate(diff));
currentWeekStart.value.setHours(0, 0, 0, 0);
currentWeekEnd.value = new Date(currentWeekStart.value);
currentWeekEnd.value.setDate(currentWeekStart.value.getDate() + 6);
currentWeekEnd.value.setHours(23, 59, 59, 999);
selectedDate.value = formatDateForInput(currentWeekStart.value);
};
// Format date for input field (YYYY-MM-DD)
const formatDateForInput = (date) => {
return date.toISOString().split("T")[0];
};
// Format week range for display
const formatWeekRange = (start, end) => {
const options = { month: "short", day: "numeric" };
const startStr = start.toLocaleDateString("en-US", options);
const endStr = end.toLocaleDateString("en-US", options);
const year = start.getFullYear();
return `${startStr} - ${endStr}, ${year}`;
};
// Week navigation functions
const previousWeek = () => {
currentWeekStart.value.setDate(currentWeekStart.value.getDate() - 7);
currentWeekEnd.value.setDate(currentWeekEnd.value.getDate() - 7);
selectedDate.value = formatDateForInput(currentWeekStart.value);
emitWeekChange();
};
const nextWeek = () => {
currentWeekStart.value.setDate(currentWeekStart.value.getDate() + 7);
currentWeekEnd.value.setDate(currentWeekEnd.value.getDate() + 7);
selectedDate.value = formatDateForInput(currentWeekStart.value);
emitWeekChange();
};
const jumpToWeek = () => {
const selectedDateObj = new Date(selectedDate.value);
const dayOfWeek = selectedDateObj.getDay();
const diff = selectedDateObj.getDate() - dayOfWeek;
currentWeekStart.value = new Date(selectedDateObj.setDate(diff));
currentWeekStart.value.setHours(0, 0, 0, 0);
currentWeekEnd.value = new Date(currentWeekStart.value);
currentWeekEnd.value.setDate(currentWeekStart.value.getDate() + 6);
currentWeekEnd.value.setHours(23, 59, 59, 999);
showDatePicker.value = false;
emitWeekChange();
};
// Handle weekly filter toggle
const handleWeeklyToggle = () => {
if (!weeklyFilterEnabled.value) {
showDatePicker.value = false;
}
emitWeekChange();
};
// Emit week change to parent
const emitWeekChange = () => {
const weekParams = weeklyFilterEnabled.value
? {
weekStartDate: formatDateForInput(currentWeekStart.value),
weekEndDate: formatDateForInput(currentWeekEnd.value),
}
: null;
if (props.onWeekChange) {
props.onWeekChange(weekParams);
}
};
// Handle view changes
const handleViewChange = () => {
updateChart();
};
// Handle category selection
const toggleCategory = (categoryLabel) => {
const index = selectedCategories.value.indexOf(categoryLabel);
if (index === -1) {
selectedCategories.value.push(categoryLabel);
} else {
selectedCategories.value.splice(index, 1);
}
updateChart();
};
// Get all unique statuses from selected categories
const getAllStatusesFromCategories = () => {
if (!props.statusCategories || selectedCategories.value.length === 0) return [];
const allStatuses = [];
const statusMap = new Map();
props.statusCategories.forEach((category) => {
if (selectedCategories.value.includes(category.label)) {
category.statuses.forEach((status) => {
if (!statusMap.has(status.label)) {
statusMap.set(status.label, status);
allStatuses.push(status);
}
});
}
});
return allStatuses;
};
// Get all unique statuses from API data (new format)
const getAllStatusesFromApiData = () => {
if (!Array.isArray(props.statusData) || selectedCategories.value.length === 0) return [];
const allStatuses = [];
const statusMap = new Map();
props.statusData.forEach((category) => {
if (selectedCategories.value.includes(category.label)) {
category.statuses.forEach((status) => {
if (!statusMap.has(status.label)) {
statusMap.set(status.label, status);
allStatuses.push(status);
}
});
}
});
return allStatuses;
};
// Get available categories from either statusData (API) or statusCategories (prop)
const getAvailableCategories = () => {
if (Array.isArray(props.statusData)) {
return props.statusData;
}
return props.statusCategories || [];
};
// Get aggregated data for selected categories
const getAggregatedCategoryData = () => {
if (!props.statusData || selectedCategories.value.length === 0) {
return {};
}
const aggregatedData = {};
// If statusData is an array (new API format)
if (Array.isArray(props.statusData)) {
// Initialize all status counts to 0
const allStatuses = getAllStatusesFromApiData();
allStatuses.forEach((status) => {
aggregatedData[status.label] = 0;
});
// Sum up data from selected categories
props.statusData.forEach((category) => {
if (selectedCategories.value.includes(category.label)) {
category.statuses.forEach((status) => {
aggregatedData[status.label] =
(aggregatedData[status.label] || 0) + status.count;
});
}
});
} else {
// Fallback to old format
const allStatuses = getAllStatusesFromCategories();
// Initialize all status counts to 0
allStatuses.forEach((status) => {
aggregatedData[status.label] = 0;
});
// Sum up data from all selected categories
props.statusCategories.forEach((category) => {
if (selectedCategories.value.includes(category.label)) {
const categoryData = props.statusData[category.label] || {};
category.statuses.forEach((status) => {
const value =
categoryData[status.label] ||
categoryData[status.label.toLowerCase().replace(" ", "_")] ||
0;
aggregatedData[status.label] = (aggregatedData[status.label] || 0) + value;
});
}
});
}
return aggregatedData;
};
// Get current view data
const getCurrentViewData = () => {
if (!props.statusData) return {};
// If statusData is an array (new API format), use the new category system
if (Array.isArray(props.statusData)) {
return getAggregatedCategoryData();
}
// If using old category system with statusCategories prop
if (props.statusCategories && props.statusCategories.length > 0) {
return getAggregatedCategoryData();
}
// Fallback to old system
if (selectedView.value === "totals") {
return props.statusData.totals || {};
}
// For categories, need to map the API response structure
const categories = props.statusData.categories || props.statusData.counts || {};
return categories[selectedView.value] || {};
};
// Helper function to get status value (handles camelCase conversion)
const getStatusValue = (viewData, statusKey) => {
// Handle both formats: "Not Started" and "not_started"
const camelCaseMapping = {
"Not Started": "not_started",
"In Progress": "in_progress",
Completed: "completed",
};
const camelKey = camelCaseMapping[statusKey];
return viewData[statusKey] || viewData[camelKey] || 0;
};
// Get current status configuration (either from categories or fallback)
const getCurrentStatusConfig = () => {
// If statusData is an array (new API format)
if (Array.isArray(props.statusData) && selectedCategories.value.length > 0) {
return getAllStatusesFromApiData();
}
// If using old category system with statusCategories prop
if (
props.statusCategories &&
props.statusCategories.length > 0 &&
selectedCategories.value.length > 0
) {
return getAllStatusesFromCategories();
}
// Fallback to old system
return props.statusConfig;
};
// Calculate total from view data
const getTotalFromViewData = (viewData) => {
const statusConfig = getCurrentStatusConfig();
return statusConfig.reduce((total, config) => {
const key = config.label || config.key;
return total + getStatusValue(viewData, key);
}, 0);
};
// Check if we have data
const hasData = computed(() => {
const viewData = getCurrentViewData();
const total = getTotalFromViewData(viewData);
return total > 0;
});
// Update center data display
const updateCenterData = () => {
const viewData = getCurrentViewData();
const total = getTotalFromViewData(viewData);
if (total === 0) {
centerData.value = {
label: "No Data",
value: "0",
percentage: null,
};
return;
}
if (hoveredSegment.value !== null) {
// Show specific segment data when hovered
const statusConfig = getCurrentStatusConfig();
const config = statusConfig[hoveredSegment.value];
const key = config.label || config.key;
const value = getStatusValue(viewData, key);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) + "%" : "0%";
centerData.value = {
label: config.label || config.key,
value: value,
percentage: percentage,
};
} else {
// Show total data
let label = "Total Count";
// Check if we're using API data format or statusCategories
const availableCategories = getAvailableCategories();
if (availableCategories && availableCategories.length > 0) {
if (selectedCategories.value.length === 1) {
label = selectedCategories.value[0];
} else if (selectedCategories.value.length > 1) {
label = "Combined Categories";
}
} else if (props.viewOptions && props.viewOptions.length > 0) {
const currentViewOption = props.viewOptions.find(
(option) => option.value === selectedView.value,
);
label =
selectedView.value === "totals"
? "Total Count"
: currentViewOption?.label || "Total";
}
centerData.value = {
label: label,
value: total,
percentage: null,
};
}
};
// Get chart data
const getChartData = () => {
const viewData = getCurrentViewData();
const statusConfig = getCurrentStatusConfig();
const data = statusConfig.map((config) => {
const key = config.label || config.key;
return getStatusValue(viewData, key);
});
// Check if we have any data
const hasDataValues = data.some((value) => value > 0);
if (!hasDataValues) {
return {
labels: ["No Data"],
datasets: [
{
data: [1],
backgroundColor: ["#e5e7eb"],
borderWidth: 0,
hoverBackgroundColor: ["#e5e7eb"],
},
],
};
}
return {
labels: statusConfig.map((config) => config.label || config.key),
datasets: [
{
data,
backgroundColor: statusConfig.map((config) => config.color),
borderWidth: 2,
borderColor: "#ffffff",
hoverBackgroundColor: statusConfig.map(
(config) => config.hoverColor || config.color,
),
hoverBorderWidth: 3,
},
],
};
};
// Chart options
const getChartOptions = () => {
return {
responsive: true,
maintainAspectRatio: false,
cutout: "60%",
plugins: {
legend: {
position: "bottom",
labels: {
padding: 20,
usePointStyle: true,
font: { size: 12 },
},
},
tooltip: { enabled: false },
},
elements: {
arc: {
borderWidth: 2,
borderColor: "#ffffff",
},
},
animation: {
animateRotate: true,
animateScale: true,
duration: 1000,
easing: "easeOutQuart",
},
interaction: {
mode: "nearest",
intersect: true,
},
onHover: (event, elements) => {
const viewData = getCurrentViewData();
const total = getTotalFromViewData(viewData);
if (total === 0) return;
if (elements && elements.length > 0) {
const elementIndex = elements[0].index;
if (hoveredSegment.value !== elementIndex) {
hoveredSegment.value = elementIndex;
updateCenterData();
}
} else {
if (hoveredSegment.value !== null) {
hoveredSegment.value = null;
updateCenterData();
}
}
},
};
};
// Create chart
const createChart = () => {
if (!chartCanvas.value || props.loading) return;
const ctx = chartCanvas.value.getContext("2d");
if (chartInstance.value) {
chartInstance.value.destroy();
}
chartInstance.value = new Chart(ctx, {
type: "doughnut",
data: getChartData(),
options: getChartOptions(),
});
updateCenterData();
};
// Update chart
const updateChart = () => {
if (props.loading || !chartInstance.value) {
return;
}
const newData = getChartData();
chartInstance.value.data = newData;
chartInstance.value.update("active");
updateCenterData();
};
// Watch for prop changes
watch(
() => props.statusData,
(newData) => {
// Reinitialize categories when statusData changes (new API data arrives)
if (Array.isArray(newData) && newData.length > 0) {
selectedCategories.value = newData.map((category) => category.label);
}
if (!props.loading) {
updateChart();
}
},
{ deep: true },
);
watch(
() => props.loading,
(newLoading, oldLoading) => {
if (oldLoading && !newLoading) {
nextTick(() => {
createChart();
});
}
},
);
// Initialize on mount
onMounted(() => {
initializeCurrentWeek();
// Initialize selected categories - select all by default
const availableCategories = getAvailableCategories();
if (availableCategories && availableCategories.length > 0) {
selectedCategories.value = availableCategories.map((category) => category.label);
}
nextTick(() => {
if (!props.loading) {
createChart();
}
// Emit initial week change
emitWeekChange();
});
});
// Cleanup on unmount
onUnmounted(() => {
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
});
</script>
<style scoped>
.status-chart-container {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
min-height: 400px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 8px;
z-index: 10;
gap: 15px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e5e7eb;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-text {
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.week-navigation {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 15px;
gap: 15px;
}
.week-navigation.disabled {
opacity: 0.6;
pointer-events: none;
}
.filter-toggle {
display: flex;
align-items: center;
justify-content: center;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
}
.toggle-checkbox {
display: none;
}
.toggle-slider {
position: relative;
width: 50px;
height: 24px;
background: #e5e7eb;
border-radius: 24px;
transition: all 0.3s ease;
cursor: pointer;
}
.toggle-slider::before {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.toggle-checkbox:checked + .toggle-slider {
background: #3b82f6;
}
.toggle-checkbox:checked + .toggle-slider::before {
transform: translateX(26px);
}
.toggle-text {
font-weight: 500;
color: #374151;
}
.week-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.all-time-display {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.all-time-text {
font-weight: 500;
color: #64748b;
font-size: 14px;
}
.nav-button {
background: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.nav-button:hover {
background: #e5e7eb;
border-color: #9ca3af;
}
.week-display {
display: flex;
align-items: center;
gap: 10px;
font-weight: 500;
color: #374151;
}
.date-picker-button {
background: none;
border: none;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.date-picker-button:hover {
color: #374151;
background: #f3f4f6;
}
.date-picker-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
border-radius: 8px;
}
.date-picker-content {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
display: flex;
flex-direction: column;
gap: 15px;
align-items: center;
}
.date-input {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
}
.close-button {
background: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background 0.2s;
}
.close-button:hover {
background: #dc2626;
}
.category-selector {
margin-bottom: 20px;
}
.selector-label {
display: block;
font-weight: 500;
color: #374151;
margin-bottom: 10px;
font-size: 14px;
}
.category-multiselect {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.category-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 2px solid #d1d5db;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
user-select: none;
}
.category-option:hover {
border-color: #3b82f6;
background: #f8fafc;
}
.category-option.selected {
border-color: #3b82f6;
background: #eff6ff;
color: #1d4ed8;
}
.category-checkbox {
margin: 0;
cursor: pointer;
}
.category-label {
font-weight: 500;
cursor: pointer;
}
.view-selector {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.view-dropdown {
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 14px;
cursor: pointer;
min-width: 200px;
}
.chart-wrapper {
position: relative;
height: 300px;
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.chart-canvas {
max-height: 100%;
width: 100% !important;
height: 100% !important;
}
.center-data {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
transition: all 0.3s ease;
}
.center-label {
font-size: 14px;
font-weight: 500;
color: #6b7280;
margin-bottom: 5px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.center-value {
font-size: 32px;
font-weight: bold;
color: #111827;
line-height: 1;
margin-bottom: 2px;
}
.center-percentage {
font-size: 16px;
font-weight: 600;
color: #3b82f6;
}
/* Responsive design */
@media (max-width: 640px) {
.status-chart-container {
padding: 15px;
}
.week-navigation {
gap: 10px;
}
.week-controls {
flex-direction: column;
gap: 10px;
}
.week-display {
order: -1;
text-align: center;
}
.nav-button {
min-width: 120px;
justify-content: center;
}
.view-dropdown {
min-width: 180px;
}
.center-value {
font-size: 24px;
}
.center-percentage {
font-size: 14px;
}
.category-multiselect {
justify-content: flex-start;
}
.category-option {
font-size: 12px;
padding: 6px 10px;
}
}
</style>