1077 lines
23 KiB
Vue
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>
|