feat: Initialize License Tracker Pro v1.2 project
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.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { License, AIInsight } from '../types';
|
||||
import { EditIcon, TrashIcon, CalendarIcon, PriceIcon, UserIcon, BriefcaseIcon, ArrowUpIcon, ArrowDownIcon } from './Icons';
|
||||
import PriceHistoryChart from './PriceHistoryChart';
|
||||
import { getAIInsights } from '../graphService';
|
||||
|
||||
interface LicenseItemProps {
|
||||
license: License;
|
||||
onEdit?: (license: License) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0);
|
||||
const formatDate = (dateStr: string | undefined | null) => dateStr ? new Date(dateStr).toLocaleDateString() : 'N/A';
|
||||
|
||||
const InfoPill: React.FC<{ icon: React.ReactNode; label: string; value: string; secondary?: React.ReactNode }> = ({ icon, label, value, secondary }) => (
|
||||
<div className="flex items-center space-x-2 bg-slate-700/50 p-2.5 rounded-xl border border-slate-600/30">
|
||||
<div className="text-cyan-400 bg-slate-900/50 p-1.5 rounded-lg">{icon}</div>
|
||||
<div className="overflow-hidden flex-1">
|
||||
<p className="text-[9px] text-gray-500 uppercase font-black tracking-widest">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs font-bold text-gray-200 truncate">{value}</p>
|
||||
{secondary}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LicenseItem: React.FC<LicenseItemProps> = ({ license, onEdit, onDelete, isReadOnly }) => {
|
||||
const [aiInsight, setAiInsight] = useState<AIInsight | null>(null);
|
||||
const [loadingAi, setLoadingAi] = useState(false);
|
||||
|
||||
const handleGetAIAdvice = async () => {
|
||||
setLoadingAi(true);
|
||||
try { setAiInsight(await getAIInsights(license)); } finally { setLoadingAi(false); }
|
||||
};
|
||||
|
||||
// Calculate Price Delta
|
||||
const priceAnalysis = useMemo(() => {
|
||||
if (!license.renewals || license.renewals.length === 0) return null;
|
||||
|
||||
// Find the most recent renewal entry to compare against
|
||||
const sortedRenewals = [...license.renewals].sort((a, b) =>
|
||||
new Date(b.renewalDate).getTime() - new Date(a.renewalDate).getTime()
|
||||
);
|
||||
|
||||
const previousPrice = sortedRenewals[0].renewalPrice;
|
||||
if (previousPrice === 0 || license.purchasePrice === previousPrice) return null;
|
||||
|
||||
const delta = ((license.purchasePrice - previousPrice) / previousPrice) * 100;
|
||||
return {
|
||||
percent: Math.abs(delta).toFixed(1),
|
||||
isIncrease: delta > 0
|
||||
};
|
||||
}, [license.purchasePrice, license.renewals]);
|
||||
|
||||
return (
|
||||
<div className={`bg-slate-800 rounded-2xl shadow-2xl overflow-hidden border transition-all duration-300 ${license.isActive ? 'border-slate-700/50 hover:border-cyan-500/50' : 'border-red-900/30 grayscale opacity-75'}`}>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8">
|
||||
<div className="md:col-span-3 flex flex-col items-center">
|
||||
<div className="relative group">
|
||||
<img src={license.image || `https://ui-avatars.com/api/?name=${encodeURIComponent(license.licenseName)}&background=1e293b&color=22d3ee&bold=true`} className="w-32 h-32 object-cover rounded-3xl border-4 border-slate-700 shadow-2xl transition-transform group-hover:scale-105"/>
|
||||
{!license.isActive && <div className="absolute inset-0 bg-red-950/40 rounded-3xl flex items-center justify-center font-black text-white text-[10px] uppercase tracking-widest">Archived</div>}
|
||||
</div>
|
||||
|
||||
{license.files && license.files.length > 0 && (
|
||||
<div className="mt-6 w-full space-y-2">
|
||||
<p className="text-[9px] font-black text-slate-500 uppercase text-center tracking-widest border-b border-slate-700 pb-1 mb-2">Attachments</p>
|
||||
{license.files.map(f => (
|
||||
<a key={f.id} href={`./api.php?action=file_download&id=${f.id}`} className="flex items-center justify-between bg-slate-900/50 hover:bg-slate-900 p-2 rounded-lg text-[10px] text-cyan-400 group border border-slate-700/50 transition-colors" title={f.fileName}>
|
||||
<span className="truncate flex-1 font-bold">{f.fileName}</span>
|
||||
<span className="text-[8px] bg-slate-800 px-1 py-0.5 rounded text-gray-500 ml-1">↓</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-9 space-y-5">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h3 className="text-3xl font-black text-white leading-none tracking-tight">{license.licenseName}</h3>
|
||||
{license.tags?.map(tag => <span key={tag} className="bg-cyan-500/10 border border-cyan-500/20 text-cyan-400 text-[9px] font-black px-2 py-0.5 rounded-full uppercase tracking-widest">{tag}</span>)}
|
||||
</div>
|
||||
<div className="flex flex-col text-xs text-slate-400 mt-2 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-black text-white bg-slate-700 px-2 py-0.5 rounded-lg tracking-tight uppercase text-[10px]">{license.companyName || 'Global'}</span>
|
||||
<span className="flex items-center font-medium"><BriefcaseIcon className="w-3.5 h-3.5 mr-1 text-cyan-400"/>
|
||||
{license.vendorName} {license.vendorContact.name && `• ${license.vendorContact.name}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-1 text-[11px] opacity-75">
|
||||
{license.vendorContact.email && <span className="flex items-center">📧 {license.vendorContact.email}</span>}
|
||||
{license.vendorContact.phone && <span className="flex items-center">📞 {license.vendorContact.phone}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleGetAIAdvice} disabled={loadingAi} className="text-[10px] font-black bg-cyan-500/10 text-cyan-400 border border-cyan-500/20 px-3 py-1.5 rounded-xl hover:bg-cyan-500/20 transition-all uppercase tracking-widest disabled:opacity-50">
|
||||
{loadingAi ? 'AI Analyzing...' : 'Contract Insight'}
|
||||
</button>
|
||||
{!isReadOnly && (
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={() => onEdit?.(license)} className="p-2 rounded-xl bg-slate-900 border border-slate-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 transition-all"><EditIcon className="w-4 h-4"/></button>
|
||||
<button onClick={() => onDelete?.(license.id)} className="p-2 rounded-xl bg-slate-900 border border-slate-700 hover:border-red-500 text-gray-400 hover:text-red-400 transition-all"><TrashIcon className="w-4 h-4"/></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{aiInsight && (
|
||||
<div className={`p-4 rounded-xl text-xs border animate-in slide-in-from-right duration-500 ${aiInsight.status === 'saving' ? 'bg-green-900/20 border-green-500/30 text-green-300' : aiInsight.status === 'warning' ? 'bg-red-900/20 border-red-500/30 text-red-300' : 'bg-slate-900/50 border-slate-700 text-gray-300'}`}>
|
||||
<p className="font-black uppercase tracking-widest text-[10px] mb-2 opacity-60">🤖 Automated Strategy</p>
|
||||
<p className="leading-relaxed font-medium">{aiInsight.advice}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<InfoPill icon={<UserIcon className="w-4 h-4"/>} label="Owner" value={license.responsiblePerson || 'Unassigned'} />
|
||||
<InfoPill
|
||||
icon={<PriceIcon className="w-4 h-4"/>}
|
||||
label="Term Price"
|
||||
value={formatCurrency(license.purchasePrice)}
|
||||
secondary={priceAnalysis && (
|
||||
<span className={`flex items-center text-[10px] font-black px-1.5 py-0.5 rounded ${priceAnalysis.isIncrease ? 'text-red-400 bg-red-400/10' : 'text-green-400 bg-green-400/10'}`}>
|
||||
{priceAnalysis.isIncrease ? <ArrowUpIcon className="w-2.5 h-2.5 mr-0.5" /> : <ArrowDownIcon className="w-2.5 h-2.5 mr-0.5" />}
|
||||
{priceAnalysis.percent}%
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<InfoPill icon={<CalendarIcon className="w-4 h-4"/>} label="Start Date" value={formatDate(license.purchaseDate)} />
|
||||
<InfoPill icon={<CalendarIcon className="w-4 h-4 text-orange-400"/>} label="Renewal/End" value={formatDate(license.endDate)} />
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/40 p-4 rounded-2xl border border-slate-700/40 shadow-inner">
|
||||
<p className="text-[9px] font-black text-slate-600 uppercase tracking-widest mb-3 text-center">Price Performance Analysis</p>
|
||||
<PriceHistoryChart license={license} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseItem;
|
||||
Reference in New Issue
Block a user