feat: implement dynamic date selection with custom range support
- Add dynamic date range generation based on actual order data - Implement custom date range picker with start/end date inputs - Unify date filtering logic between Analytics and ProfitAnalysis pages - Support multiple date formats (YYYY, YYYY-MM, YYYY-QX, custom ranges) - Remove hardcoded years for future-proof date selection - Enhance ProfitAnalysisService with custom date range support
This commit is contained in:
parent
db752f55f4
commit
701c805d0c
3 changed files with 259 additions and 24 deletions
|
|
@ -11,7 +11,92 @@ const Analytics = () => {
|
||||||
const { customers } = useSelector((state: RootState) => state.customers);
|
const { customers } = useSelector((state: RootState) => state.customers);
|
||||||
const { expenses } = useSelector((state: RootState) => state.expenses);
|
const { expenses } = useSelector((state: RootState) => state.expenses);
|
||||||
|
|
||||||
const [dateRange, setDateRange] = useState('2026');
|
const [dateRange, setDateRange] = useState(() => {
|
||||||
|
// Default to current year
|
||||||
|
return new Date().getFullYear().toString();
|
||||||
|
});
|
||||||
|
const [customStartDate, setCustomStartDate] = useState('');
|
||||||
|
const [customEndDate, setCustomEndDate] = useState('');
|
||||||
|
|
||||||
|
// Generate dynamic date range options based on actual order data
|
||||||
|
const dateRangeOptions = useMemo(() => {
|
||||||
|
const presets = [
|
||||||
|
{ value: 'all', label: 'All Time', type: 'preset' },
|
||||||
|
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
|
||||||
|
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
|
||||||
|
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
|
||||||
|
{ value: 'year', label: 'This Year (Calendar)', type: 'preset' },
|
||||||
|
{ value: 'custom', label: 'Custom Date Range', type: 'preset' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!orders || orders.length === 0) {
|
||||||
|
return {
|
||||||
|
presets,
|
||||||
|
years: [],
|
||||||
|
quarters: [],
|
||||||
|
months: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get date ranges from actual order data
|
||||||
|
const years = new Set<number>();
|
||||||
|
const months = new Set<string>();
|
||||||
|
const quarters = new Set<string>();
|
||||||
|
|
||||||
|
orders.forEach(order => {
|
||||||
|
if (order.dateOrdered) {
|
||||||
|
const date = new Date(order.dateOrdered);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
const quarter = Math.floor(month / 3) + 1;
|
||||||
|
|
||||||
|
years.add(year);
|
||||||
|
months.add(`${year}-${String(month + 1).padStart(2, '0')}`);
|
||||||
|
quarters.add(`${year}-Q${quarter}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add year options (sorted newest first)
|
||||||
|
const yearOptions = Array.from(years)
|
||||||
|
.sort((a, b) => b - a)
|
||||||
|
.map(year => ({
|
||||||
|
value: year.toString(),
|
||||||
|
label: year.toString(),
|
||||||
|
type: 'year'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add quarter options (last 8 quarters)
|
||||||
|
const quarterOptions = Array.from(quarters)
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map(quarter => ({
|
||||||
|
value: quarter,
|
||||||
|
label: quarter.replace('-', ' '),
|
||||||
|
type: 'quarter'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Add month options (last 24 months)
|
||||||
|
const monthOptions = Array.from(months)
|
||||||
|
.sort((a, b) => b.localeCompare(a))
|
||||||
|
.slice(0, 24)
|
||||||
|
.map(monthKey => {
|
||||||
|
const [year, month] = monthKey.split('-');
|
||||||
|
const date = new Date(parseInt(year), parseInt(month) - 1);
|
||||||
|
const label = date.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
||||||
|
return {
|
||||||
|
value: monthKey,
|
||||||
|
label,
|
||||||
|
type: 'month'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
presets,
|
||||||
|
years: yearOptions,
|
||||||
|
quarters: quarterOptions,
|
||||||
|
months: monthOptions
|
||||||
|
};
|
||||||
|
}, [orders]);
|
||||||
|
|
||||||
// Helper function to get updated printing cost using current product costs
|
// Helper function to get updated printing cost using current product costs
|
||||||
const getUpdatedPrintingCost = (order: any) => {
|
const getUpdatedPrintingCost = (order: any) => {
|
||||||
|
|
@ -45,6 +130,15 @@ const Analytics = () => {
|
||||||
return orderYear === targetYear && orderQuarter === targetQuarter;
|
return orderYear === targetYear && orderQuarter === targetQuarter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
|
||||||
|
if (dateRange.includes('_')) {
|
||||||
|
const [startDate, endDate] = dateRange.split('_');
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999); // Include the end date
|
||||||
|
return orderDate >= start && orderDate <= end;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
||||||
if (dateRange.match(/^\d{4}$/)) {
|
if (dateRange.match(/^\d{4}$/)) {
|
||||||
return orderDate.getFullYear() === parseInt(dateRange);
|
return orderDate.getFullYear() === parseInt(dateRange);
|
||||||
|
|
@ -64,6 +158,15 @@ const Analytics = () => {
|
||||||
case 'year':
|
case 'year':
|
||||||
// For "This Year" option, show current calendar year
|
// For "This Year" option, show current calendar year
|
||||||
return orderDate.getFullYear() === now.getFullYear();
|
return orderDate.getFullYear() === now.getFullYear();
|
||||||
|
case 'custom':
|
||||||
|
// Handle custom date range using state variables
|
||||||
|
if (customStartDate && customEndDate) {
|
||||||
|
const start = new Date(customStartDate);
|
||||||
|
const end = new Date(customEndDate);
|
||||||
|
end.setHours(23, 59, 59, 999); // Include the end date
|
||||||
|
return orderDate >= start && orderDate <= end;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -95,6 +198,15 @@ const Analytics = () => {
|
||||||
return expenseYear === targetYear && expenseQuarter === targetQuarter;
|
return expenseYear === targetYear && expenseQuarter === targetQuarter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
|
||||||
|
if (dateRange.includes('_')) {
|
||||||
|
const [startDate, endDate] = dateRange.split('_');
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
end.setHours(23, 59, 59, 999); // Include the end date
|
||||||
|
return expenseDate >= start && expenseDate <= end;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
// Handle year format (e.g., "2026") - calendar year, not rolling 365 days
|
||||||
if (dateRange.match(/^\d{4}$/)) {
|
if (dateRange.match(/^\d{4}$/)) {
|
||||||
return expenseDate.getFullYear() === parseInt(dateRange);
|
return expenseDate.getFullYear() === parseInt(dateRange);
|
||||||
|
|
@ -114,6 +226,15 @@ const Analytics = () => {
|
||||||
case 'year':
|
case 'year':
|
||||||
// For "This Year" option, show current calendar year
|
// For "This Year" option, show current calendar year
|
||||||
return expenseDate.getFullYear() === now.getFullYear();
|
return expenseDate.getFullYear() === now.getFullYear();
|
||||||
|
case 'custom':
|
||||||
|
// Handle custom date range using state variables
|
||||||
|
if (customStartDate && customEndDate) {
|
||||||
|
const start = new Date(customStartDate);
|
||||||
|
const end = new Date(customEndDate);
|
||||||
|
end.setHours(23, 59, 59, 999); // Include the end date
|
||||||
|
return expenseDate >= start && expenseDate <= end;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -304,27 +425,45 @@ const Analytics = () => {
|
||||||
value={dateRange}
|
value={dateRange}
|
||||||
onChange={(e) => setDateRange(e.target.value)}
|
onChange={(e) => setDateRange(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="week">Last Week</option>
|
{/* Preset Ranges */}
|
||||||
<option value="month">Last Month</option>
|
{dateRangeOptions.presets.map(option => (
|
||||||
<option value="quarter">Last Quarter</option>
|
<option key={option.value} value={option.value}>
|
||||||
<option value="year">This Year (2026)</option>
|
{option.label}
|
||||||
<option value="2026">2026 Full Year</option>
|
</option>
|
||||||
<option value="2025">2025 Full Year</option>
|
))}
|
||||||
<optgroup label="2026 Months">
|
|
||||||
<option value="2026-05">May 2026</option>
|
{/* Years */}
|
||||||
<option value="2026-04">April 2026</option>
|
{dateRangeOptions.years.length > 0 && (
|
||||||
<option value="2026-03">March 2026</option>
|
<optgroup label="Years">
|
||||||
<option value="2026-02">February 2026</option>
|
{dateRangeOptions.years.map(option => (
|
||||||
<option value="2026-01">January 2026</option>
|
<option key={option.value} value={option.value}>
|
||||||
</optgroup>
|
{option.label}
|
||||||
<optgroup label="2025 Months">
|
</option>
|
||||||
<option value="2025-12">December 2025</option>
|
))}
|
||||||
<option value="2025-11">November 2025</option>
|
</optgroup>
|
||||||
<option value="2025-10">October 2025</option>
|
)}
|
||||||
<option value="2025-09">September 2025</option>
|
|
||||||
<option value="2025-08">August 2025</option>
|
{/* Quarters */}
|
||||||
<option value="2025-07">July 2025</option>
|
{dateRangeOptions.quarters.length > 0 && (
|
||||||
</optgroup>
|
<optgroup label="Quarters">
|
||||||
|
{dateRangeOptions.quarters.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Months */}
|
||||||
|
{dateRangeOptions.months.length > 0 && (
|
||||||
|
<optgroup label="Months">
|
||||||
|
{dateRangeOptions.months.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
|
|
@ -333,6 +472,51 @@ const Analytics = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range Inputs */}
|
||||||
|
{dateRange === 'custom' && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="startDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="startDate"
|
||||||
|
value={customStartDate}
|
||||||
|
onChange={(e) => setCustomStartDate(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="endDate"
|
||||||
|
value={customEndDate}
|
||||||
|
onChange={(e) => setCustomEndDate(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (customStartDate && customEndDate) {
|
||||||
|
setDateRange(`${customStartDate}_${customEndDate}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
|
disabled={!customStartDate || !customEndDate}
|
||||||
|
>
|
||||||
|
Apply Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key Metrics */}
|
{/* Key Metrics */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6 mb-8">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,12 @@ const ProfitAnalysis = () => {
|
||||||
const { orders } = useSelector((state: RootState) => state.orders);
|
const { orders } = useSelector((state: RootState) => state.orders);
|
||||||
const { products } = useSelector((state: RootState) => state.products);
|
const { products } = useSelector((state: RootState) => state.products);
|
||||||
const { expenses } = useSelector((state: RootState) => state.expenses);
|
const { expenses } = useSelector((state: RootState) => state.expenses);
|
||||||
const [dateRange, setDateRange] = useState('all');
|
const [dateRange, setDateRange] = useState(() => {
|
||||||
|
// Default to current year
|
||||||
|
return new Date().getFullYear().toString();
|
||||||
|
});
|
||||||
|
const [customStartDate, setCustomStartDate] = useState('');
|
||||||
|
const [customEndDate, setCustomEndDate] = useState('');
|
||||||
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
|
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
|
||||||
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
|
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
|
||||||
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
|
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
|
||||||
|
|
@ -146,6 +151,51 @@ const ProfitAnalysis = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range Inputs */}
|
||||||
|
{dateRange === 'custom' && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="startDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="startDate"
|
||||||
|
value={customStartDate}
|
||||||
|
onChange={(e) => setCustomStartDate(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="endDate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
End Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="endDate"
|
||||||
|
value={customEndDate}
|
||||||
|
onChange={(e) => setCustomEndDate(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="pt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (customStartDate && customEndDate) {
|
||||||
|
setDateRange(`${customStartDate}_${customEndDate}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400"
|
||||||
|
disabled={!customStartDate || !customEndDate}
|
||||||
|
>
|
||||||
|
Apply Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* View Selector */}
|
{/* View Selector */}
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,8 @@ export class ProfitAnalysisService {
|
||||||
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
|
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
|
||||||
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
|
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
|
||||||
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
|
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
|
||||||
{ value: 'year', label: 'Last 365 Days', type: 'preset' }
|
{ value: 'year', label: 'Last 365 Days', type: 'preset' },
|
||||||
|
{ value: 'custom', label: 'Custom Date Range', type: 'custom' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Get unique months from orders
|
// Get unique months from orders
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue