e045354892
Adds initial project structure, dependencies, and basic configuration for the License Tracker Pro v1.2 application. This includes setting up Vite for the build process, defining core types, and incorporating essential UI components like icons and a timeline. The project is now ready for development.
175 lines
7.9 KiB
TypeScript
175 lines
7.9 KiB
TypeScript
import React from 'react';
|
|
import { License } from '../types';
|
|
import { ChevronLeftIcon, ChevronRightIcon } from './Icons';
|
|
|
|
interface TimelineProps {
|
|
licenses: License[];
|
|
timelineDate: Date;
|
|
onNavigate: (direction: 'prev' | 'next') => void;
|
|
fiscalStartMonth: number;
|
|
}
|
|
|
|
const COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#4ade80', '#facc15', '#fb923c', '#f87171', '#60a5fa'];
|
|
|
|
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime());
|
|
|
|
const getColorForString = (str: string): string => {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = (hash << 5) - hash + str.charCodeAt(i);
|
|
hash = hash & hash;
|
|
}
|
|
return COLORS[Math.abs(hash) % COLORS.length];
|
|
};
|
|
|
|
const getFiscalYearBounds = (refDate: Date, startMonth: number): [Date, Date] => {
|
|
try {
|
|
const year = isValidDate(refDate) ? refDate.getFullYear() : new Date().getFullYear();
|
|
const safeMonth = (!startMonth || isNaN(startMonth) || startMonth < 1 || startMonth > 12) ? 4 : Number(startMonth);
|
|
let start = new Date(year, safeMonth - 1, 1);
|
|
|
|
if (refDate < start) {
|
|
start.setFullYear(year - 1);
|
|
}
|
|
|
|
const end = new Date(start.getFullYear() + 1, start.getMonth(), 0);
|
|
end.setHours(23, 59, 59, 999);
|
|
return [start, end];
|
|
} catch (e) {
|
|
const now = new Date();
|
|
const s = new Date(now.getFullYear(), 0, 1);
|
|
const e_ = new Date(now.getFullYear(), 11, 31, 23, 59, 59);
|
|
return [s, e_];
|
|
}
|
|
};
|
|
|
|
const percentThroughDateRange = (date: Date, start: Date, end: Date): number => {
|
|
if (!isValidDate(date) || !isValidDate(start) || !isValidDate(end)) return 0;
|
|
if (date < start) return 0;
|
|
if (date > end) return 100;
|
|
const total = end.getTime() - start.getTime();
|
|
return total > 0 ? ((date.getTime() - start.getTime()) / total) * 100 : 0;
|
|
};
|
|
|
|
interface TimelineEvent {
|
|
type: string;
|
|
date: Date;
|
|
label: string;
|
|
color: string;
|
|
isEndDate: boolean;
|
|
percent: number;
|
|
stackLevel: number;
|
|
}
|
|
|
|
const Timeline: React.FC<TimelineProps> = ({ licenses = [], timelineDate, onNavigate, fiscalStartMonth }) => {
|
|
const safeDate = isValidDate(timelineDate) ? timelineDate : new Date();
|
|
const [fyStart, fyEnd] = getFiscalYearBounds(safeDate, fiscalStartMonth);
|
|
|
|
const safeLicenses = Array.isArray(licenses) ? licenses : [];
|
|
|
|
const events: TimelineEvent[] = safeLicenses.flatMap(lic => {
|
|
if (!lic) return [];
|
|
const color = getColorForString(lic.licenseName || 'U');
|
|
const list: TimelineEvent[] = [];
|
|
|
|
const add = (dStr: string | undefined, type: string, isEnd: boolean) => {
|
|
if (!dStr) return;
|
|
const d = new Date(dStr);
|
|
if (isValidDate(d)) {
|
|
list.push({ type, date: d, label: lic.licenseName, color, isEndDate: isEnd, percent: 0, stackLevel: 0 });
|
|
}
|
|
};
|
|
|
|
// ONLY show the current active contract term
|
|
add(lic.purchaseDate, 'Current Term Start', false);
|
|
add(lic.endDate, 'Current Term Expiry', true);
|
|
|
|
// NOTE: Historical renewals are intentionally excluded from timeline visualization
|
|
// to focus on current contract status and upcoming expirations.
|
|
|
|
return list;
|
|
});
|
|
|
|
const visibleEvents = events
|
|
.filter(e => e.date >= fyStart && e.date <= fyEnd)
|
|
.map(e => ({ ...e, percent: percentThroughDateRange(e.date, fyStart, fyEnd) }))
|
|
.sort((a, b) => a.percent - b.percent);
|
|
|
|
const today = new Date();
|
|
const todayPercent = percentThroughDateRange(today, fyStart, fyEnd);
|
|
const isTodayVisible = today >= fyStart && today <= fyEnd;
|
|
|
|
const levelLastPositions: number[] = [];
|
|
visibleEvents.forEach(e => {
|
|
let level = 0, placed = false;
|
|
while (!placed && level < 10) {
|
|
if (e.percent > (levelLastPositions[level] || -100) + 4.0) {
|
|
e.stackLevel = level;
|
|
levelLastPositions[level] = e.percent;
|
|
placed = true;
|
|
} else level++;
|
|
}
|
|
if (!placed) e.stackLevel = level;
|
|
});
|
|
|
|
const containerHeight = 60 + (Math.max(0, ...visibleEvents.map(e => e.stackLevel)) * 22);
|
|
const monthLabels = Array.from({length: 12}, (_, i) => {
|
|
const d = new Date(fyStart.getFullYear(), fyStart.getMonth() + i, 1);
|
|
return d.toLocaleString('default', { month: 'short' });
|
|
});
|
|
|
|
return (
|
|
<div className="bg-slate-800 p-6 rounded-lg shadow-2xl mb-8 border border-slate-700/50">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-100">Fiscal Timeline</h2>
|
|
<p className="text-xs text-gray-500 font-mono uppercase tracking-tight">Period: {fyStart.toLocaleDateString()} - {fyEnd.toLocaleDateString()}</p>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<button onClick={() => onNavigate('prev')} className="p-1.5 rounded-full bg-slate-900 border border-slate-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 transition-all shadow-lg"><ChevronLeftIcon /></button>
|
|
<button onClick={() => onNavigate('next')} className="p-1.5 rounded-full bg-slate-900 border border-slate-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 transition-all shadow-lg"><ChevronRightIcon /></button>
|
|
</div>
|
|
</div>
|
|
<div className="relative mt-8">
|
|
<div className="flex justify-between text-[10px] font-black text-slate-500 mb-3 border-b border-slate-700 pb-2 uppercase tracking-widest">
|
|
{monthLabels.map((m, i) => <div key={i} className="flex-1 text-center">{m}</div>)}
|
|
</div>
|
|
|
|
<div className="relative rounded-lg bg-slate-900/40 w-full" style={{ height: `${containerHeight}px` }}>
|
|
{isTodayVisible && (
|
|
<div className="absolute top-0 bottom-0 w-0.5 bg-red-500/80 z-20 pointer-events-none shadow-[0_0_8px_rgba(239,68,68,0.5)]" style={{ left: `${todayPercent}%` }}>
|
|
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-full mb-1">
|
|
<span className="bg-red-500 text-white text-[9px] font-black px-1.5 py-0.5 rounded shadow-lg uppercase tracking-tighter">Today</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="absolute top-1/2 left-0 right-0 h-px bg-slate-700 -translate-y-1/2"></div>
|
|
{visibleEvents.map((event, i) => {
|
|
const isTop = event.stackLevel % 2 === 0;
|
|
const offset = Math.ceil(event.stackLevel / 2) * 20 + 8;
|
|
return (
|
|
<div key={i} className="absolute -translate-x-1/2 group z-10" style={{ left: `${event.percent}%`, top: isTop ? `calc(50% - ${offset}px)` : `calc(50% + ${offset}px)` }}>
|
|
<div
|
|
className={`w-3.5 h-3.5 rounded-full border-2 cursor-pointer transition-all duration-200 group-hover:scale-150 group-hover:z-30 shadow-md ${event.isEndDate ? 'bg-transparent' : ''}`}
|
|
style={{ backgroundColor: event.isEndDate ? 'transparent' : event.color, borderColor: event.color }}
|
|
></div>
|
|
<div className="hidden group-hover:block absolute bottom-full mb-3 left-1/2 -translate-x-1/2 bg-slate-900 text-white text-xs rounded-lg py-2 px-3 whitespace-nowrap shadow-2xl z-40 border border-slate-600 ring-4 ring-black/20">
|
|
<div className="flex items-center space-x-2 mb-1">
|
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: event.color }}></div>
|
|
<span className="font-bold text-cyan-400">{event.label}</span>
|
|
</div>
|
|
<div className="text-[10px] text-gray-300">
|
|
<span className="font-semibold text-white">{event.type}</span> • {event.date.toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div className="absolute w-px bg-slate-600/40 -z-10 left-1/2" style={{ top: isTop ? '100%' : 'auto', bottom: isTop ? 'auto' : '100%', height: `${offset - 8}px` }}></div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Timeline; |