Files
Philip e045354892 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.
2026-01-26 18:55:43 -08:00

318 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { License, Renewal, Contact, NotificationGroup } from '../types';
import { TrashIcon, PlusIcon, BriefcaseIcon, SaveIcon, BellIcon, ArchiveIcon } from './Icons';
import { fetchContacts, fetchGroups, deleteFile } from '../graphService';
interface LicenseFormProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (license: License & { newFiles?: any[] }) => void;
initialData?: License | null;
}
const emptyLicense: Omit<License, 'id'> = {
licenseName: '', companyName: '', responsiblePerson: '',
purchaseDate: new Date().toISOString().split('T')[0],
endDate: '', purchasePrice: 0, vendorName: '',
vendorContact: { name: '', email: '', phone: '' },
notificationGroupId: null, comments: '', renewals: [],
tags: [], isActive: true, image: ''
};
const LicenseForm: React.FC<LicenseFormProps> = ({ isOpen, onClose, onSubmit, initialData }) => {
const [license, setLicense] = useState<Omit<License, 'id'>>({ ...emptyLicense });
const [tagsInput, setTagsInput] = useState<string>('');
const [newFiles, setNewFiles] = useState<{ name: string; type: string; data: string }[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [groups, setGroups] = useState<NotificationGroup[]>([]);
const [loading, setLoading] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isOpen) {
fetchContacts().then(d => setContacts(Array.isArray(d) ? d : []));
fetchGroups().then(d => setGroups(Array.isArray(d) ? d : []));
setNewFiles([]);
}
if (initialData) {
setLicense({ ...initialData });
setTagsInput(initialData.tags?.join(', ') || '');
} else {
setLicense({ ...emptyLicense, renewals: [], tags: [] });
setTagsInput('');
}
}, [initialData, isOpen]);
if (!isOpen) return null;
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (evt) => {
const result = evt.target?.result;
if (typeof result === 'string') {
setLicense(prev => ({ ...prev, image: result }));
}
};
reader.readAsDataURL(file);
}
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
Array.from(e.target.files).forEach((file: File) => {
const reader = new FileReader();
reader.onload = (evt: ProgressEvent<FileReader>) => {
const result = evt.target?.result;
if (typeof result === 'string') {
setNewFiles(prev => [...prev, {
name: file.name,
type: file.type,
data: result
}]);
}
};
reader.readAsDataURL(file);
});
}
};
const handleVendorSelection = (vendorName: string) => {
if (!vendorName) {
setLicense(prev => ({
...prev,
vendorName: '',
vendorContact: { name: '', email: '', phone: '' }
}));
return;
}
const vendor = contacts.find(c => c.vendorName === vendorName);
if (vendor) {
setLicense(prev => ({
...prev,
vendorName: vendor.vendorName,
vendorContact: {
name: vendor.contactName || '',
email: vendor.contactEmail || '',
phone: vendor.contactPhone || ''
}
}));
} else {
setLicense(prev => ({ ...prev, vendorName }));
}
};
const handleMigrateToHistory = () => {
if (!license.purchaseDate) {
alert("Current contract needs a start date to migrate.");
return;
}
const newRenewal: Renewal = {
id: `renewal-migrated-${Date.now()}`,
renewalDate: license.purchaseDate,
endDate: license.endDate,
renewalPrice: license.purchasePrice,
notes: 'Archived from primary record'
};
setLicense(prev => ({
...prev,
renewals: [newRenewal, ...prev.renewals],
purchaseDate: new Date().toISOString().split('T')[0],
endDate: '',
purchasePrice: 0
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!license.licenseName) return;
setLoading(true);
try {
const finalId = initialData?.id || `license-${Date.now()}`;
// Parse tags from the raw input string
const parsedTags = tagsInput
.split(',')
.map(t => t.trim())
.filter(t => t.length > 0);
await onSubmit({
...license,
id: finalId,
tags: parsedTags,
newFiles
} as any);
} catch (err: any) {
console.error("Submit error", err);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black/80 flex justify-center items-start pt-10 z-50 overflow-y-auto backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-slate-800 rounded-2xl shadow-2xl p-8 w-full max-w-5xl m-4 text-gray-200 border border-slate-700">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-cyan-400">{initialData ? 'Update License Details' : 'Register New License'}</h2>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm text-gray-400 cursor-pointer">
<input type="checkbox" checked={license.isActive} onChange={e => setLicense({...license, isActive: e.target.checked})} className="w-4 h-4 rounded bg-slate-900 border-slate-600 text-cyan-500 focus:ring-0" />
Active Record
</label>
<button onClick={onClose} className="text-gray-500 hover:text-white text-2xl p-1" type="button">&times;</button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex flex-col md:flex-row gap-8 items-start bg-slate-900/40 p-6 rounded-2xl border border-slate-700/50 mb-4">
<div className="relative group cursor-pointer w-32 h-32 flex-shrink-0" onClick={() => logoInputRef.current?.click()}>
<img src={license.image || `https://ui-avatars.com/api/?name=${encodeURIComponent(license.licenseName || 'L')}&background=1e293b&color=22d3ee&bold=true`} className="w-full h-full object-cover rounded-3xl border-4 border-slate-700 shadow-2xl transition-transform group-hover:scale-105" />
<div className="absolute inset-0 bg-black/40 rounded-3xl flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] font-black uppercase text-white tracking-widest">Change Logo</span>
</div>
<input type="file" ref={logoInputRef} className="hidden" accept="image/*" onChange={handleLogoUpload} />
</div>
<div className="flex-1 space-y-4 w-full">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label className="label-style">License Name</label><input name="licenseName" value={license.licenseName} onChange={e => setLicense({...license, licenseName: e.target.value})} required className="input-style text-lg font-bold" placeholder="e.g. Photoshop Pro" /></div>
<div><label className="label-style">Company / Department</label><input name="companyName" value={license.companyName} onChange={e => setLicense({...license, companyName: e.target.value})} className="input-style" placeholder="e.g. Marketing Dept" /></div>
</div>
<div>
<label className="label-style">Tags (comma separated)</label>
<input
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
className="input-style"
placeholder="IT, SaaS, Creative"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="space-y-4">
<label className="label-style">Alerting & Support</label>
<div>
<label className="label-style opacity-50">Notification Group</label>
<select value={license.notificationGroupId || ''} onChange={e => setLicense({...license, notificationGroupId: e.target.value ? parseInt(e.target.value) : null})} className="input-style">
<option value="">None (No alerts)</option>
{groups.map(g => <option key={g.id} value={g.id}>{g.groupName}</option>)}
</select>
</div>
<div>
<label className="label-style opacity-50">Notes / Comments</label>
<textarea value={license.comments} onChange={e => setLicense({...license, comments: e.target.value})} className="input-style h-24 resize-none" placeholder="Internal usage guidelines..." />
</div>
</div>
<div className="space-y-4 bg-slate-900/40 p-5 rounded-2xl border border-slate-700/50 shadow-inner">
<div className="flex justify-between items-center mb-1">
<label className="text-[10px] font-black uppercase tracking-widest text-cyan-400">Current Term</label>
<button type="button" onClick={handleMigrateToHistory} className="text-[9px] font-bold bg-cyan-500/10 text-cyan-300 px-2 py-1 rounded border border-cyan-500/20 hover:bg-cyan-500/20 transition-colors uppercase">Archive Term</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="label-style">Purchase Date</label><input type="date" value={license.purchaseDate} onChange={e => setLicense({...license, purchaseDate: e.target.value})} className="input-style" /></div>
<div><label className="label-style">Expiry Date</label><input type="date" value={license.endDate || ''} onChange={e => setLicense({...license, endDate: e.target.value})} className="input-style" /></div>
</div>
<div><label className="label-style">Subscription Cost (USD)</label><input type="number" step="0.01" value={license.purchasePrice} onChange={e => setLicense({...license, purchasePrice: parseFloat(e.target.value) || 0})} className="input-style" /></div>
<div><label className="label-style">Responsible Person / Owner</label><input value={license.responsiblePerson} onChange={e => setLicense({...license, responsiblePerson: e.target.value})} className="input-style" placeholder="e.g. John Smith" /></div>
</div>
<div className="space-y-4">
<label className="label-style">Vendor & Point of Contact</label>
<div className="space-y-2">
<select
value={contacts.some(c => c.vendorName === license.vendorName) ? license.vendorName : ""}
onChange={e => handleVendorSelection(e.target.value)}
className="input-style"
>
<option value="">-- Select Existing Vendor --</option>
{contacts.map(c => <option key={c.id} value={c.vendorName}>{c.vendorName}</option>)}
<option value="NEW_VENDOR">+ Register New Vendor</option>
</select>
<input
placeholder="Vendor Name"
value={license.vendorName}
onChange={e => setLicense({...license, vendorName: e.target.value})}
className="input-style"
/>
</div>
<input placeholder="Contact Person" value={license.vendorContact.name} onChange={e => setLicense({...license, vendorContact: {...license.vendorContact, name: e.target.value}})} className="input-style" />
<div className="grid grid-cols-1 gap-2">
<input placeholder="Contact Email" value={license.vendorContact.email} onChange={e => setLicense({...license, vendorContact: {...license.vendorContact, email: e.target.value}})} className="input-style" />
<input placeholder="Contact Phone" value={license.vendorContact.phone} onChange={e => setLicense({...license, vendorContact: {...license.vendorContact, phone: e.target.value}})} className="input-style" />
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="flex justify-between items-center"><h3 className="font-bold text-gray-300 text-sm">Price & Term History</h3><button type="button" onClick={() => setLicense({...license, renewals: [{id: `r-${Date.now()}`, renewalDate: '', renewalPrice: 0, notes: ''}, ...license.renewals]})} className="text-cyan-400 text-[10px] font-black uppercase tracking-widest hover:underline">+ Add Entry</button></div>
<div className="space-y-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
{license.renewals.map((r, i) => (
<div key={r.id} className="grid grid-cols-12 gap-2 items-center bg-slate-900/40 p-3 rounded-xl border border-slate-700/50">
<input type="date" className="col-span-4 input-style py-1.5 text-xs" value={r.renewalDate} onChange={e => {const rn=[...license.renewals]; rn[i].renewalDate=e.target.value; setLicense({...license, renewals: rn})}} />
<input type="number" step="0.01" className="col-span-3 input-style py-1.5 text-xs" value={r.renewalPrice} onChange={e => {const rn=[...license.renewals]; rn[i].renewalPrice=parseFloat(e.target.value) || 0; setLicense({...license, renewals: rn})}} />
<input placeholder="Term notes..." className="col-span-4 input-style py-1.5 text-xs" value={r.notes || ''} onChange={e => {const rn=[...license.renewals]; rn[i].notes=e.target.value; setLicense({...license, renewals: rn})}} />
<button type="button" onClick={() => setLicense({...license, renewals: license.renewals.filter((_, idx)=>idx!==i)})} className="col-span-1 text-red-500 hover:text-red-400 font-bold text-lg leading-none">×</button>
</div>
))}
{license.renewals.length === 0 && <p className="text-[10px] text-slate-600 italic p-4 text-center border border-dashed border-slate-700 rounded-xl">No historical records.</p>}
</div>
</div>
<div className="space-y-4">
<h3 className="font-bold text-gray-300 text-sm">Contract Documents</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-3">
<p className="text-[10px] uppercase font-black text-slate-500 tracking-widest">Storage</p>
<div className="space-y-1.5 max-h-32 overflow-y-auto custom-scrollbar">
{license.files?.map(f => (
<div key={f.id} className="flex justify-between items-center text-xs bg-slate-900/40 p-2.5 rounded-lg border border-slate-700/50">
<span className="truncate flex-1 text-cyan-300 font-medium">{f.fileName}</span>
<button type="button" onClick={async () => { if(confirm('Remove this document?')) { await deleteFile(f.id); setLicense({...license, files: license.files?.filter(x=>x.id!==f.id)}); } }} className="text-red-500 ml-2 hover:scale-110 transition-transform px-1">&times;</button>
</div>
))}
{(!license.files || license.files.length === 0) && <p className="text-[10px] text-slate-600 italic">Vault is empty.</p>}
</div>
</div>
<div className="space-y-3">
<p className="text-[10px] uppercase font-black text-slate-500 tracking-widest">Add Files</p>
<div className="relative border-2 border-dashed border-slate-700 rounded-2xl p-4 text-center hover:border-cyan-500 transition-colors bg-slate-900/20">
<input type="file" multiple onChange={handleFileUpload} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"/>
<PlusIcon className="w-5 h-5 mx-auto text-slate-600 mb-1" />
<p className="text-[10px] text-slate-500">Drop files here</p>
</div>
<div className="text-[10px] text-cyan-400 space-y-1">
{newFiles.map(f => <div key={f.name} className="flex items-center gap-1"> <span className="truncate">{f.name}</span></div>)}
</div>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-4 pt-6 border-t border-slate-700">
<button type="button" onClick={onClose} className="px-6 py-2.5 rounded-xl bg-slate-700 hover:bg-slate-600 font-bold transition-all text-sm">Discard</button>
<button type="submit" disabled={loading} className="px-10 py-2.5 rounded-xl bg-cyan-600 hover:bg-cyan-500 text-white font-bold shadow-lg shadow-cyan-900/30 transition-all active:scale-95 flex items-center gap-2 disabled:opacity-50">
<SaveIcon className="w-4 h-4"/> {loading ? 'Committing...' : 'Commit Changes'}
</button>
</div>
</form>
</div>
<style>{`
.label-style { display: block; font-size: 10px; font-weight: 900; color: #64748b; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 4px; margin-left: 4px; }
.input-style { background: #0f172a; border: 1px solid #334155; color: #e5e7eb; border-radius: 12px; padding: 10px 14px; width: 100%; transition: all 0.2s; font-size: 0.875rem; }
.input-style:focus { outline: none; border-color: #22d3ee; box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.1); }
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; }
`}</style>
</div>
);
};
export default LicenseForm;