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:
Philip
2026-01-26 18:55:43 -08:00
parent d4895c6d81
commit e045354892
56 changed files with 3410 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
^jWv+-u h
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+256
View File
@@ -0,0 +1,256 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { License, User, AppSettings } from './types';
import Timeline from './components/Timeline';
import LicenseList from './components/LicenseList';
import LicenseForm from './components/LicenseForm';
import Header from './components/Header';
import LoginForm from './components/LoginForm';
import UserManagement from './components/UserManagement';
import AdminSettings from './components/AdminSettings';
import AuditLogs from './components/AuditLogs';
import DashboardAlerts from './components/DashboardAlerts';
import { PlusIcon, TrashIcon } from './components/Icons';
import { fetchLicenses, saveLicense, deleteLicense, exportDatabase, importDatabase, checkSession, logout, fetchSettings, DEFAULT_SETTINGS, runNotifications } from './graphService';
const App: React.FC = () => {
const [licenses, setLicenses] = useState<License[]>([]);
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [currentView, setCurrentView] = useState<string>('dashboard');
const [isModalOpen, setIsModalOpen] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [editingLicense, setEditingLicense] = useState<License | null>(null);
const [timelineDate, setTimelineDate] = useState(new Date());
const [searchQuery, setSearchQuery] = useState('');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'name' | 'price_desc' | 'price_asc' | 'end_soon'>('name');
const [showArchived, setShowArchived] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const initApp = useCallback(async () => {
setIsLoading(true);
try {
// Trigger background notification checks
runNotifications().catch(e => console.debug("Notifications silent run:", e));
const [settings, session, data] = await Promise.all([
fetchSettings().catch(() => DEFAULT_SETTINGS),
checkSession().catch(() => ({ authenticated: false })),
fetchLicenses().catch(() => [])
]);
if (settings) {
setAppSettings({ ...DEFAULT_SETTINGS, ...settings });
}
if (session?.authenticated) setCurrentUser(session.user);
setLicenses(Array.isArray(data) ? data : []);
} catch (e) {
console.error("Init Error", e);
setLicenses([]);
} finally { setIsLoading(false); }
}, []);
useEffect(() => { initApp(); }, [initApp]);
const handleFormSubmit = async (licenseData: any) => {
try {
await saveLicense(licenseData);
const freshData = await fetchLicenses();
setLicenses(Array.isArray(freshData) ? freshData : []);
setIsModalOpen(false);
setEditingLicense(null);
} catch (err: any) {
alert("Save Failed: " + err.message);
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
if(e.target.files?.[0]) {
try {
const text = await e.target.files[0].text();
const res = await importDatabase(text);
// CRITICAL: Clear all UI filters before refreshing data
setSearchQuery('');
setSelectedTags([]);
setShowArchived(false);
await initApp();
const count = res.count || 'all';
alert(`Restore Complete. ${count} licenses were successfully recovered to the repository.`);
} catch (err: any) {
alert("Restore failed: " + err.message);
} finally {
e.target.value = '';
}
}
};
const allTags = useMemo(() => {
const tags = new Set<string>();
if (Array.isArray(licenses)) {
licenses.forEach(l => l.tags?.forEach(t => tags.add(t)));
}
return Array.from(tags).sort();
}, [licenses]);
const toggleTag = (tag: string) => {
setSelectedTags(prev =>
prev.includes(tag)
? prev.filter(t => t !== tag)
: [...prev, tag]
);
};
const filteredAndSorted = useMemo(() => {
const safeLicenses = Array.isArray(licenses) ? licenses : [];
let filtered = safeLicenses.filter(l => {
if (!l) return false;
const statusMatch = showArchived ? !l.isActive : l.isActive;
if (!statusMatch) return false;
if (selectedTags.length > 0) {
const hasAllTags = selectedTags.every(tag => l.tags?.includes(tag));
if (!hasAllTags) return false;
}
const search = searchQuery.toLowerCase();
return !search ||
(l.licenseName && l.licenseName.toLowerCase().includes(search)) ||
(l.companyName && l.companyName.toLowerCase().includes(search));
});
return filtered.sort((a, b) => {
if (sortBy === 'price_desc') return b.purchasePrice - a.purchasePrice;
if (sortBy === 'price_asc') return a.purchasePrice - b.purchasePrice;
if (sortBy === 'end_soon') {
const da = a.endDate ? new Date(a.endDate).getTime() : Infinity;
const db = b.endDate ? new Date(b.endDate).getTime() : Infinity;
return da - db;
}
return (a.licenseName || '').localeCompare(b.licenseName || '');
});
}, [licenses, searchQuery, showArchived, selectedTags, sortBy]);
return (
<div className="min-h-screen bg-slate-950 text-gray-200 p-4 sm:p-8">
<div className="max-w-7xl mx-auto">
<Header
user={currentUser}
onLogout={async () => { await logout(); setCurrentUser(null); initApp(); }}
onLoginClick={() => setShowLoginModal(true)}
onExport={async () => {
try {
const j = await exportDatabase();
const jsonString = JSON.stringify(j, null, 2);
const b = new Blob([jsonString], {type:'application/json'});
const u = URL.createObjectURL(b);
const a = document.createElement('a');
a.href = u;
a.download = `licenses_backup_${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(u);
} catch (err: any) {
alert("Export failed: " + err.message);
}
}}
onImport={handleImport}
currentView={currentView}
onChangeView={setCurrentView}
/>
{currentView === 'users' ? <UserManagement /> : currentView === 'logs' ? <AuditLogs /> : currentView === 'settings' ? <AdminSettings onSettingsUpdate={setAppSettings} /> : (
<main className="animate-in fade-in duration-500">
<DashboardAlerts licenses={filteredAndSorted} alertDays={appSettings.alert_days} />
<Timeline licenses={filteredAndSorted} timelineDate={timelineDate} onNavigate={(dir) => setTimelineDate(d => { const n = new Date(d); n.setFullYear(d.getFullYear() + (dir === 'next' ? 1 : -1)); return n; })} fiscalStartMonth={appSettings.fiscal_start_month} />
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4">
<h2 className="text-2xl font-black uppercase tracking-tighter text-white">{showArchived ? 'Archive' : 'Active Inventory'}</h2>
<div className="flex gap-2">
<button onClick={() => setShowArchived(!showArchived)} className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-widest transition-all border ${showArchived ? 'bg-cyan-600 border-cyan-500 text-white' : 'bg-slate-900 border-slate-700 hover:border-cyan-500'}`}>
{showArchived ? 'View Active' : 'View Archived'}
</button>
{currentUser && (
<button onClick={() => { setEditingLicense(null); setIsModalOpen(true); }} className="bg-cyan-600 text-white px-5 py-2 rounded-xl font-bold hover:bg-cyan-500 shadow-lg shadow-cyan-900/30 transition-all active:scale-95 flex items-center">
<PlusIcon className="mr-2 w-4 h-4"/> New License
</button>
)}
</div>
</div>
<div className="mb-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<input
placeholder="Search records by name or company..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:border-cyan-500 outline-none transition-all shadow-inner"
/>
</div>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as any)}
className="bg-slate-900 border border-slate-800 rounded-xl px-4 py-3 text-sm focus:border-cyan-500 outline-none transition-all"
>
<option value="name">Sort by Name</option>
<option value="price_desc">Price: High to Low</option>
<option value="price_asc">Price: Low to High</option>
<option value="end_soon">Expiration: Soonest</option>
</select>
</div>
<div className="bg-slate-900/40 p-4 rounded-2xl border border-slate-800/50">
<div className="flex items-center justify-between mb-3">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-500">Filter by Tags</label>
{selectedTags.length > 0 && (
<button
onClick={() => setSelectedTags([])}
className="text-[10px] font-bold text-red-400 hover:text-red-300 uppercase tracking-tighter"
>
Clear Filters ({selectedTags.length})
</button>
)}
</div>
<div className="flex flex-wrap gap-2">
{allTags.length === 0 ? (
<span className="text-xs text-slate-600 italic">No tags available in current inventory.</span>
) : (
allTags.map(tag => {
const isActive = selectedTags.includes(tag);
return (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${
isActive
? 'bg-cyan-500/20 border-cyan-500 text-cyan-300 shadow-[0_0_10px_rgba(34,211,238,0.2)]'
: 'bg-slate-800/50 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
}`}
>
{tag}
</button>
);
})
)}
</div>
</div>
</div>
{isLoading ? (
<div className="text-center py-20 text-cyan-400 font-mono text-sm animate-pulse tracking-widest">LOADING REPOSITORY...</div>
) : (
<LicenseList licenses={filteredAndSorted} onEdit={(l) => { setEditingLicense(l); setIsModalOpen(true); }} onDelete={async (id) => { if(confirm('Delete?')) { await deleteLicense(id); initApp(); } }} isReadOnly={!currentUser} />
)}
</main>
)}
</div>
<LicenseForm isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} onSubmit={handleFormSubmit} initialData={editingLicense} />
{showLoginModal && <LoginForm settings={appSettings} onLoginSuccess={(u) => { setCurrentUser(u); setShowLoginModal(false); initApp(); }} onCancel={() => setShowLoginModal(false)} />}
</div>
);
};
export default App;
View File
+1
View File
@@ -0,0 +1 @@

+1
View File
@@ -0,0 +1 @@

+17 -8
View File
@@ -1,11 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
<h1>Built with AI Studio</h2>
<p>The fastest path from prompt to production with Gemini.</p>
<a href="https://aistudio.google.com/apps">Start building</a>
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1u0HmRwxVrSebL4-XJ7PDx9D60tcijzFc
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`
View File
View File
+379
View File
@@ -0,0 +1,379 @@
<?php
/**
* LICENSE TRACKER PRO - COMPLETE UNIFIED API BACKEND
*/
// 1. SYSTEM CONFIGURATION
error_reporting(E_ALL);
ini_set('display_errors', 0);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
header('Content-Type: application/json');
// 2. DATABASE CONNECTION
$db_host = getenv('DB_HOST') ?: 'db';
$db_name = getenv('DB_NAME') ?: 'license_tracker';
$db_user = getenv('DB_USER') ?: 'root';
$db_pass = getenv('DB_PASS') ?: 'root_password';
try {
$pdo = new PDO("mysql:host=$db_host;dbname=$db_name;charset=utf8mb4", $db_user, $db_pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_TIMEOUT => 10
]);
} catch (PDOException $e) {
http_response_code(500);
echo json_encode(['error' => 'Database connection failed: ' . $e->getMessage()]);
exit;
}
// 3. SCHEMA INITIALIZATION & MIGRATIONS
try {
$pdo->exec("CREATE TABLE IF NOT EXISTS settings (id INT PRIMARY KEY DEFAULT 1, config LONGTEXT NOT NULL) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS users (id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, email VARCHAR(100), role ENUM('admin', 'editor') DEFAULT 'editor', auth_source VARCHAR(20) DEFAULT 'local', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS licenses (id VARCHAR(50) PRIMARY KEY, licenseName VARCHAR(255) NOT NULL, companyName VARCHAR(255), responsiblePerson VARCHAR(255), purchaseDate DATE, endDate DATE, purchasePrice DECIMAL(15, 2), vendorName VARCHAR(255), vendorContact LONGTEXT, notificationGroupId INT NULL, comments TEXT, tags TEXT, isActive BOOLEAN DEFAULT 1, image LONGTEXT, alert_level INT DEFAULT 0, last_alert_at DATETIME NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS renewals (id VARCHAR(50) PRIMARY KEY, license_id VARCHAR(50), renewalDate DATE, endDate DATE, renewalPrice DECIMAL(15, 2), notes TEXT, FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS files (id INT AUTO_INCREMENT PRIMARY KEY, license_id VARCHAR(50), fileName VARCHAR(255), mimeType VARCHAR(100), fileSize INT, fileData LONGBLOB, FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS notification_groups (id INT AUTO_INCREMENT PRIMARY KEY, groupName VARCHAR(100) NOT NULL, emails TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS contacts (id INT AUTO_INCREMENT PRIMARY KEY, vendorName VARCHAR(255) UNIQUE NOT NULL, contactName VARCHAR(255), contactEmail VARCHAR(255), contactPhone VARCHAR(50)) ENGINE=InnoDB;");
$pdo->exec("CREATE TABLE IF NOT EXISTS audit_logs (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT, username VARCHAR(50), action_type VARCHAR(50), entity_type VARCHAR(50), entity_id VARCHAR(50), entity_name VARCHAR(255), details TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB;");
// Dynamic Column Checks
$cols = $pdo->query("DESCRIBE licenses")->fetchAll(PDO::FETCH_COLUMN);
if (!in_array('image', $cols)) $pdo->exec("ALTER TABLE licenses ADD COLUMN image LONGTEXT AFTER isActive");
if (!in_array('alert_level', $cols)) $pdo->exec("ALTER TABLE licenses ADD COLUMN alert_level INT DEFAULT 0 AFTER image");
if (!in_array('last_alert_at', $cols)) $pdo->exec("ALTER TABLE licenses ADD COLUMN last_alert_at DATETIME NULL AFTER alert_level");
if ($pdo->query("SELECT COUNT(*) FROM users")->fetchColumn() == 0) {
$hash = password_hash('admin123', PASSWORD_DEFAULT);
$pdo->prepare("INSERT INTO users (username, password, role, email) VALUES (?, ?, 'admin', 'admin@example.com')")->execute(['admin', $hash]);
}
} catch (Exception $e) {}
// 4. HELPERS
function respond($data, $code = 200) {
http_response_code($code);
echo json_encode($data);
exit;
}
function isAuth() { if (!isset($_SESSION['user'])) respond(['error' => 'Authentication Required'], 401); }
function isAdmin() { isAuth(); if (($_SESSION['user']['role'] ?? '') !== 'admin') respond(['error' => 'Admin access required'], 403); }
function logAudit($pdo, $type, $entity, $id, $name, $details) {
try {
$stmt = $pdo->prepare("INSERT INTO audit_logs (user_id, username, action_type, entity_type, entity_id, entity_name, details) VALUES (?,?,?,?,?,?,?)");
$stmt->execute([$_SESSION['user']['id'] ?? 0, $_SESSION['user']['username'] ?? 'system', $type, $entity, (string)$id, $name, $details]);
} catch (Exception $e) {}
}
/**
* Enhanced SMTP Socket Mailer
*/
function sendEmail($settings, $to, $subject, $body) {
if (empty($settings['smtp_host'])) return "SMTP Host is missing in configuration.";
$host = $settings['smtp_host'];
$port = $settings['smtp_port'];
$user = $settings['smtp_user'];
$pass = $settings['smtp_pass'];
$from = $settings['smtp_from'] ?: 'tracker@system.local';
$address = ($port == 465) ? "ssl://$host" : $host;
$socket = @fsockopen($address, $port, $errno, $errstr, 15);
if (!$socket) return "Connection Failed to $address:$port. Error: $errstr ($errno)";
$getResponse = function($socket) {
$res = "";
while($str = fgets($socket, 515)) {
$res .= $str;
if(substr($str, 3, 1) == " ") break;
}
return $res;
};
$sendCommand = function($socket, $cmd) use ($getResponse) {
fputs($socket, $cmd . "\r\n");
return $getResponse($socket);
};
$getResponse($socket);
$sendCommand($socket, "EHLO " . $_SERVER['HTTP_HOST']);
if (!empty($user)) {
$sendCommand($socket, "AUTH LOGIN");
$sendCommand($socket, base64_encode($user));
$sendCommand($socket, base64_encode($pass));
}
$sendCommand($socket, "MAIL FROM: <$from>");
$recipients = array_map('trim', explode(',', $to));
foreach ($recipients as $recipient) { $sendCommand($socket, "RCPT TO: <$recipient>"); }
$sendCommand($socket, "DATA");
$header = "To: $to\r\nFrom: License Tracker <$from>\r\nSubject: $subject\r\n";
$header .= "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nDate: " . date("r") . "\r\n\r\n";
$sendCommand($socket, $header . $body . "\r\n.");
$sendCommand($socket, "QUIT");
fclose($socket);
return true;
}
$default_settings = [
'fiscal_start_month' => 4, 'alert_days' => 45, 'allow_local_login' => true,
'smtp_from' => '', 'smtp_host' => '', 'smtp_port' => '465', 'smtp_user' => '', 'smtp_pass' => ''
];
// 5. API ROUTING
$action = $_GET['action'] ?? '';
$input = json_decode(file_get_contents('php://input'), true) ?? [];
switch ($action) {
case 'check_session': respond(['authenticated' => isset($_SESSION['user']), 'user' => $_SESSION['user'] ?? null]); break;
case 'login':
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$input['username'] ?? '']);
$u = $stmt->fetch();
if ($u && password_verify($input['password'] ?? '', $u['password'])) {
unset($u['password']);
$_SESSION['user'] = $u;
logAudit($pdo, 'LOGIN', 'user', $u['id'], $u['username'], 'Logged in to system');
respond(['success' => true, 'user' => $u]);
}
respond(['error' => 'Invalid credentials'], 401);
break;
case 'logout':
logAudit($pdo, 'LOGOUT', 'user', $_SESSION['user']['id'] ?? 0, $_SESSION['user']['username'] ?? 'system', 'Logged out');
session_destroy();
respond(['success' => true]);
break;
case 'list':
$lics = $pdo->query("SELECT * FROM licenses ORDER BY created_at DESC")->fetchAll();
foreach ($lics as &$l) {
$l['vendorContact'] = json_decode($l['vendorContact'] ?? '{}', true);
$l['tags'] = $l['tags'] ? explode(',', $l['tags']) : [];
$l['isActive'] = (bool)$l['isActive'];
$stR = $pdo->prepare("SELECT * FROM renewals WHERE license_id = ? ORDER BY renewalDate DESC");
$stR->execute([$l['id']]);
$l['renewals'] = $stR->fetchAll();
$stF = $pdo->prepare("SELECT id, fileName, mimeType, fileSize FROM files WHERE license_id = ?");
$stF->execute([$l['id']]);
$l['files'] = $stF->fetchAll();
}
respond($lics);
break;
case 'save':
isAuth();
$isUpdate = isset($input['id']);
$id = $input['id'] ?? uniqid('lic_');
$pdo->beginTransaction();
try {
$alertLevel = 0;
if ($isUpdate) {
$stA = $pdo->prepare("SELECT alert_level FROM licenses WHERE id = ?");
$stA->execute([$id]);
$alertLevel = $stA->fetchColumn() ?: 0;
}
// 1. Upsert License
$pdo->prepare("DELETE FROM licenses WHERE id = ?")->execute([$id]);
$stmt = $pdo->prepare("INSERT INTO licenses (id, licenseName, companyName, responsiblePerson, purchaseDate, endDate, purchasePrice, vendorName, vendorContact, notificationGroupId, comments, tags, isActive, image, alert_level) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
$stmt->execute([
$id, $input['licenseName'], $input['companyName'] ?? '', $input['responsiblePerson'] ?? '',
$input['purchaseDate'], $input['endDate'] ?: null, (float)($input['purchasePrice'] ?? 0),
$input['vendorName'] ?? '', json_encode($input['vendorContact'] ?? []), $input['notificationGroupId'] ?? null,
$input['comments'] ?? '', is_array($input['tags']) ? implode(',', $input['tags']) : '', (int)($input['isActive'] ?? 1),
$input['image'] ?? null, $alertLevel
]);
// 2. Sync Renewals (History)
$pdo->prepare("DELETE FROM renewals WHERE license_id = ?")->execute([$id]);
if (!empty($input['renewals'])) {
$stR = $pdo->prepare("INSERT INTO renewals (id, license_id, renewalDate, endDate, renewalPrice, notes) VALUES (?,?,?,?,?,?)");
foreach ($input['renewals'] as $r) {
$stR->execute([$r['id'] ?: uniqid('r_'), $id, $r['renewalDate'], $r['endDate'] ?? null, (float)$r['renewalPrice'], $r['notes'] ?? '']);
}
}
// 3. Process File Attachments
if (!empty($input['newFiles'])) {
$stF = $pdo->prepare("INSERT INTO files (license_id, fileName, mimeType, fileSize, fileData) VALUES (?,?,?,?,?)");
foreach ($input['newFiles'] as $f) {
$parts = explode(',', $f['data']);
$data = count($parts) > 1 ? $parts[1] : $parts[0];
$decodedData = base64_decode($data);
$stF->execute([$id, $f['name'], $f['type'], strlen($decodedData), $decodedData]);
}
}
// 4. Update Global Contacts List
if (!empty($input['vendorName'])) {
$stC = $pdo->prepare("INSERT INTO contacts (vendorName, contactName, contactEmail, contactPhone) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE contactName=VALUES(contactName), contactEmail=VALUES(contactEmail), contactPhone=VALUES(contactPhone)");
$stC->execute([
$input['vendorName'],
$input['vendorContact']['name'] ?? '',
$input['vendorContact']['email'] ?? '',
$input['vendorContact']['phone'] ?? ''
]);
}
$pdo->commit();
logAudit($pdo, $isUpdate ? 'UPDATE' : 'CREATE', 'license', $id, $input['licenseName'], $isUpdate ? 'Updated license and associated records' : 'Created new license entry');
respond(['success' => true]);
} catch (Exception $e) { if ($pdo->inTransaction()) $pdo->rollBack(); respond(['error' => $e->getMessage()], 500); }
break;
case 'delete': isAuth(); $pdo->prepare("DELETE FROM licenses WHERE id = ?")->execute([$_GET['id']]); respond(['success' => true]); break;
case 'users_list': isAdmin(); respond($pdo->query("SELECT id, username, email, role, auth_source, created_at FROM users")->fetchAll()); break;
case 'users_create':
isAdmin();
$hash = password_hash($input['password'], PASSWORD_DEFAULT);
$pdo->prepare("INSERT INTO users (username, password, email, role, auth_source) VALUES (?,?,?,?,?)")->execute([$input['username'], $hash, $input['email'], $input['role'], $input['auth_source']]);
logAudit($pdo, 'CREATE', 'user', $pdo->lastInsertId(), $input['username'], 'Created user account');
respond(['success' => true]);
break;
case 'users_update':
isAdmin();
$id = $_GET['id'];
if (!empty($input['password'])) {
$hash = password_hash($input['password'], PASSWORD_DEFAULT);
$pdo->prepare("UPDATE users SET username=?, password=?, email=?, role=?, auth_source=? WHERE id=?")->execute([$input['username'], $hash, $input['email'], $input['role'], $input['auth_source'], $id]);
logAudit($pdo, 'UPDATE', 'user', $id, $input['username'], 'Updated user with password reset');
} else {
$pdo->prepare("UPDATE users SET username=?, email=?, role=?, auth_source=? WHERE id=?")->execute([$input['username'], $input['email'], $input['role'], $input['auth_source'], $id]);
logAudit($pdo, 'UPDATE', 'user', $id, $input['username'], 'Updated user profile');
}
respond(['success' => true]);
break;
case 'users_delete': isAdmin(); if ($_GET['id'] != 1) $pdo->prepare("DELETE FROM users WHERE id = ?")->execute([$_GET['id']]); respond(['success' => true]); break;
case 'logs_list': isAdmin(); respond($pdo->query("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 200")->fetchAll()); break;
case 'settings_get':
$s = $pdo->query("SELECT config FROM settings WHERE id = 1")->fetch();
respond($s ? array_merge($default_settings, json_decode($s['config'], true)) : $default_settings);
break;
case 'settings_save':
isAdmin();
$pdo->prepare("INSERT INTO settings (id, config) VALUES (1, ?) ON DUPLICATE KEY UPDATE config = VALUES(config)")->execute([json_encode($input)]);
logAudit($pdo, 'UPDATE', 'settings', '1', 'Configuration', 'Changed system settings');
respond(['success' => true]);
break;
case 'groups_list': respond($pdo->query("SELECT * FROM notification_groups ORDER BY groupName")->fetchAll()); break;
case 'groups_save': isAdmin(); $pdo->prepare("INSERT INTO notification_groups (groupName, emails) VALUES (?,?)")->execute([$input['groupName'], $input['emails']]); respond(['success' => true]); break;
case 'groups_delete': isAdmin(); $pdo->prepare("DELETE FROM notification_groups WHERE id = ?")->execute([$_GET['id']]); respond(['success' => true]); break;
case 'contacts_list': respond($pdo->query("SELECT * FROM contacts ORDER BY vendorName")->fetchAll()); break;
case 'contacts_save': isAuth(); $pdo->prepare("INSERT INTO contacts (vendorName, contactName, contactEmail, contactPhone) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE contactName=VALUES(contactName), contactEmail=VALUES(contactEmail), contactPhone=VALUES(contactPhone)")->execute([$input['vendorName'], $input['contactName'], $input['contactEmail'], $input['contactPhone']]); respond(['success' => true]); break;
case 'contacts_delete': isAdmin(); $pdo->prepare("DELETE FROM contacts WHERE id = ?")->execute([$_GET['id']]); respond(['success' => true]); break;
case 'run_notifications':
$s = $pdo->query("SELECT config FROM settings WHERE id = 1")->fetch();
$settings = $s ? array_merge($default_settings, json_decode($s['config'], true)) : $default_settings;
$threshold = (int)$settings['alert_days'];
$today = new DateTime();
$lics = $pdo->query("SELECT l.*, g.emails FROM licenses l LEFT JOIN notification_groups g ON l.notificationGroupId = g.id WHERE l.isActive = 1 AND l.endDate IS NOT NULL AND l.notificationGroupId IS NOT NULL")->fetchAll();
$sent = 0;
foreach ($lics as $l) {
$expiry = new DateTime($l['endDate']);
$diff = $today->diff($expiry);
$daysLeft = $diff->invert ? -$diff->days : $diff->days;
$currentLvl = (int)$l['alert_level'];
$targetLvl = 0;
if ($daysLeft <= 0) $targetLvl = 3;
else if ($daysLeft <= ($threshold / 2)) $targetLvl = 2;
else if ($daysLeft <= $threshold) $targetLvl = 1;
if ($targetLvl > $currentLvl) {
$labels = [1 => "Initial Alert", 2 => "Half-way Follow-up", 3 => "Day of Expiration!"];
$subject = "[{$labels[$targetLvl]}] License Expiry: {$l['licenseName']}";
$body = "<h2>Expiration Alert</h2><p><b>License:</b> {$l['licenseName']}<br><b>Expiry:</b> {$l['endDate']}<br><b>Stage:</b> {$labels[$targetLvl]}</p>";
if (sendEmail($settings, $l['emails'], $subject, $body) === true) {
$pdo->prepare("UPDATE licenses SET alert_level = ?, last_alert_at = NOW() WHERE id = ?")->execute([$targetLvl, $l['id']]);
logAudit($pdo, 'ACTION', 'system', $l['id'], $l['licenseName'], "Sent level $targetLvl email to {$l['emails']}");
$sent++;
}
}
}
respond(['success' => true, 'count' => $sent]);
break;
case 'test_email':
isAdmin();
$s = $pdo->query("SELECT config FROM settings WHERE id = 1")->fetch();
$settings = $s ? array_merge($default_settings, json_decode($s['config'], true)) : $default_settings;
$res = sendEmail($settings, $input['email'], "System Test", "<h1>SMTP Test Successful</h1>");
if ($res === true) respond(['success' => true]); else respond(['error' => $res], 500);
break;
case 'export':
isAdmin();
$data = [
'licenses' => $pdo->query("SELECT * FROM licenses")->fetchAll(),
'renewals' => $pdo->query("SELECT * FROM renewals")->fetchAll(),
'contacts' => $pdo->query("SELECT * FROM contacts")->fetchAll(),
'groups' => $pdo->query("SELECT * FROM notification_groups")->fetchAll(),
'files' => $pdo->query("SELECT id, license_id, fileName, mimeType, fileSize, TO_BASE64(fileData) as fileDataBase64 FROM files")->fetchAll()
];
respond($data);
break;
case 'import':
isAdmin();
try {
$pdo->beginTransaction();
$pdo->exec("SET FOREIGN_KEY_CHECKS = 0;");
$pdo->exec("DELETE FROM files; DELETE FROM renewals; DELETE FROM licenses; DELETE FROM contacts; DELETE FROM notification_groups;");
if (!empty($input['licenses'])) {
$st = $pdo->prepare("INSERT INTO licenses (id, licenseName, companyName, responsiblePerson, purchaseDate, endDate, purchasePrice, vendorName, vendorContact, notificationGroupId, comments, tags, isActive, image, alert_level) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
foreach ($input['licenses'] as $l) $st->execute([$l['id'], $l['licenseName'], $l['companyName']??'', $l['responsiblePerson']??'', $l['purchaseDate'], $l['endDate'], $l['purchasePrice'], $l['vendorName']??'', is_array($l['vendorContact'])?json_encode($l['vendorContact']):'{}', $l['notificationGroupId']??null, $l['comments']??'', is_array($l['tags'])?implode(',',$l['tags']):'', (int)($l['isActive']??1), $l['image']??null, $l['alert_level']??0]);
}
if (!empty($input['renewals'])) {
$st = $pdo->prepare("INSERT INTO renewals (id, license_id, renewalDate, endDate, renewalPrice, notes) VALUES (?,?,?,?,?,?)");
foreach ($input['renewals'] as $r) $st->execute([$r['id'], $r['license_id'], $r['renewalDate'], $r['endDate']??null, (float)$r['renewalPrice'], $r['notes']??'']);
}
if (!empty($input['files'])) {
$st = $pdo->prepare("INSERT INTO files (id, license_id, fileName, mimeType, fileSize, fileData) VALUES (?,?,?,?,?,?)");
foreach ($input['files'] as $f) { $data = base64_decode($f['fileDataBase64']??''); $st->execute([$f['id'], $f['license_id'], $f['fileName'], $f['mimeType'], $f['fileSize'], $data]); }
}
if (!empty($input['contacts'])) {
$st = $pdo->prepare("INSERT INTO contacts (id, vendorName, contactName, contactEmail, contactPhone) VALUES (?,?,?,?,?)");
foreach ($input['contacts'] as $c) $st->execute([$c['id'], $c['vendorName'], $c['contactName']??'', $c['contactEmail']??'', $c['contactPhone']??'']);
}
if (!empty($input['groups'])) {
$st = $pdo->prepare("INSERT INTO notification_groups (id, groupName, emails) VALUES (?,?,?)");
foreach ($input['groups'] as $g) $st->execute([$g['id'], $g['groupName'], $g['emails']]);
}
$pdo->exec("SET FOREIGN_KEY_CHECKS = 1;");
$pdo->commit();
logAudit($pdo, 'RESTORE', 'system', '0', 'Database', 'Imported full backup file');
respond(['success' => true]);
} catch (Exception $e) { if ($pdo->inTransaction()) $pdo->rollBack(); respond(['error' => $e->getMessage()], 500); }
break;
case 'file_delete': isAuth(); $pdo->prepare("DELETE FROM files WHERE id = ?")->execute([$_GET['id']]); respond(['success' => true]); break;
case 'file_download':
$st = $pdo->prepare("SELECT fileName, mimeType, fileData FROM files WHERE id = ?"); $st->execute([$_GET['id']]); $f = $st->fetch();
if ($f) { header("Content-Type: ".$f['mimeType']); header("Content-Disposition: attachment; filename=\"".$f['fileName']."\""); echo $f['fileData']; exit; }
respond(['error' => 'Not found'], 404);
break;
default: respond(['error' => 'Action not found: ' . $action], 404);
}
View File
View File
View File
View File
View File
+302
View File
@@ -0,0 +1,302 @@
import React, { useEffect, useState, useRef } from 'react';
import { AppSettings, User, NotificationGroup, Contact } from '../types';
import {
fetchSettings, saveSettings, sendTestEmail,
fetchUsers, createUser, deleteUser, updateUser,
fetchGroups, saveGroup, deleteGroup,
fetchContacts, saveContact, deleteContact,
DEFAULT_SETTINGS
} from '../graphService';
import { TrashIcon, PlusIcon, SaveIcon, UserIcon, BriefcaseIcon, BellIcon, SettingsIcon, EditIcon } from './Icons';
interface AdminSettingsProps {
onSettingsUpdate?: (settings: AppSettings) => void;
}
const AdminSettings: React.FC<AdminSettingsProps> = ({ onSettingsUpdate }) => {
const [activeTab, setActiveTab] = useState<'general' | 'auth' | 'users' | 'groups' | 'vendors'>('general');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{type: 'success'|'error', text: string} | null>(null);
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [users, setUsers] = useState<User[]>([]);
const [groups, setGroups] = useState<NotificationGroup[]>([]);
const [contacts, setContacts] = useState<Contact[]>([]);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const [newUser, setNewUser] = useState({
username: '', password: '', email: '', role: 'editor' as 'admin'|'editor',
auth_source: 'local' as 'local'|'azure'|'ldap', sendEmail: true
});
const [newGroup, setNewGroup] = useState({ groupName: '', emails: '' });
const [newVendor, setNewVendor] = useState({ vendorName: '', contactName: '', contactEmail: '', contactPhone: '' });
const loadAllData = async () => {
setLoading(true);
try {
const [sData, uData, gData, cData] = await Promise.all([
fetchSettings().catch(() => DEFAULT_SETTINGS),
fetchUsers().catch(() => []),
fetchGroups().catch(() => []),
fetchContacts().catch(() => [])
]);
setSettings(sData || DEFAULT_SETTINGS);
setUsers(uData || []);
setGroups(gData || []);
setContacts(cData || []);
} catch (e: any) {
setMessage({ type: 'error', text: e.message });
} finally {
setLoading(false);
}
};
useEffect(() => { loadAllData(); }, []);
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
await saveSettings(settings);
setMessage({ type: 'success', text: 'Configuration saved successfully.' });
if (onSettingsUpdate) onSettingsUpdate(settings);
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleTestEmail = async () => {
const email = prompt("Enter test recipient email:", settings.smtp_from);
if (!email) return;
setLoading(true);
try {
await sendTestEmail(email);
setMessage({ type: 'success', text: `Test email sent to ${email}.` });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateGroup = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
await saveGroup(newGroup);
const updated = await fetchGroups();
setGroups(updated || []);
setNewGroup({ groupName: '', emails: '' });
setMessage({ type: 'success', text: 'Group created.' });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateVendor = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
await saveContact(newVendor);
const updated = await fetchContacts();
setContacts(updated || []);
setNewVendor({ vendorName: '', contactName: '', contactEmail: '', contactPhone: '' });
setMessage({ type: 'success', text: 'Vendor added.' });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
const handleCreateOrUpdateUser = async (e: React.FormEvent) => {
e.preventDefault();
try {
setLoading(true);
if (editingUserId) {
const payload = { ...newUser };
if (!payload.password) delete (payload as any).password;
await updateUser(editingUserId, payload);
} else {
await createUser(newUser);
}
await loadAllData();
setEditingUserId(null);
setNewUser({ username: '', password: '', email: '', role: 'editor', auth_source: 'local', sendEmail: true });
} catch (e: any) { setMessage({ type: 'error', text: e.message }); }
finally { setLoading(false); }
};
return (
<div className="bg-slate-800 rounded-lg shadow-xl min-h-[600px] flex flex-col md:flex-row border border-slate-700 overflow-hidden">
<div className="w-full md:w-64 bg-slate-900/50 border-r border-slate-700 p-4 flex flex-col gap-2">
<h2 className="text-xl font-bold text-cyan-400 mb-4 px-2 tracking-tight">System Admin</h2>
{[
{id:'general', label:'Application', icon:<SettingsIcon className="w-4 h-4 mr-3"/>},
{id:'auth', label:'Identity & SSO', icon:<UserIcon className="w-4 h-4 mr-3"/>},
{id:'users', label:'Local Users', icon:<UserIcon className="w-4 h-4 mr-3"/>},
{id:'groups', label:'Alert Groups', icon:<BellIcon className="w-4 h-4 mr-3"/>},
{id:'vendors', label:'Vendors', icon:<BriefcaseIcon className="w-4 h-4 mr-3"/>}
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id as any)} className={`text-left px-4 py-3 rounded transition-all font-medium flex items-center ${activeTab === tab.id ? 'bg-cyan-600 text-white shadow-lg shadow-cyan-900/20' : 'text-gray-400 hover:bg-slate-700 hover:text-white'}`}>
{tab.icon} {tab.label}
</button>
))}
</div>
<div className="flex-1 p-6 overflow-y-auto">
{message && (
<div className={`mb-6 p-4 rounded-xl border flex justify-between items-center animate-in fade-in slide-in-from-top ${message.type === 'success' ? 'bg-green-900/30 border-green-600/50 text-green-400' : 'bg-red-900/30 border-red-600/50 text-red-400'}`}>
<span className="text-sm font-medium">{message.text}</span>
<button onClick={() => setMessage(null)} className="text-lg opacity-50 hover:opacity-100">&times;</button>
</div>
)}
{activeTab === 'general' && (
<form onSubmit={handleSaveSettings} className="space-y-8 animate-in fade-in duration-300">
<div>
<h3 className="text-lg font-bold text-gray-200 border-b border-slate-700 pb-2 mb-4">Core Defaults</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div><label className="label-style">Fiscal Start Month</label><select value={settings.fiscal_start_month} onChange={e => setSettings({...settings, fiscal_start_month: parseInt(e.target.value)})} className="input-style">{Array.from({length:12},(_,i)=>(<option key={i+1} value={i+1}>{new Date(0,i).toLocaleString('default',{month:'long'})}</option>))}</select></div>
<div><label className="label-style">Alert Threshold (Days)</label><input type="number" value={settings.alert_days} onChange={e => setSettings({...settings, alert_days: parseInt(e.target.value)})} className="input-style" /></div>
</div>
</div>
<div>
<div className="flex justify-between items-center border-b border-slate-700 pb-2 mb-4"><h3 className="text-lg font-bold text-gray-200">SMTP Server</h3><button type="button" onClick={handleTestEmail} className="text-[10px] font-black bg-cyan-500/10 text-cyan-400 px-3 py-1.5 rounded-lg border border-cyan-500/20 hover:bg-cyan-500/20 transition-all uppercase tracking-widest">Send Test</button></div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="SMTP Host" value={settings.smtp_host} onChange={e => setSettings({...settings, smtp_host: e.target.value})} className="input-style" />
<input placeholder="Port" value={settings.smtp_port} onChange={e => setSettings({...settings, smtp_port: e.target.value})} className="input-style" />
<input placeholder="Username" value={settings.smtp_user} onChange={e => setSettings({...settings, smtp_user: e.target.value})} className="input-style" />
<input type="password" placeholder="Password" value={settings.smtp_pass} onChange={e => setSettings({...settings, smtp_pass: e.target.value})} className="input-style" />
<input placeholder="From Address" value={settings.smtp_from} onChange={e => setSettings({...settings, smtp_from: e.target.value})} className="input-style md:col-span-2" />
</div>
</div>
<div className="flex justify-end"><button type="submit" disabled={loading} className="primary-btn">Save Settings</button></div>
</form>
)}
{activeTab === 'auth' && (
<form onSubmit={handleSaveSettings} className="space-y-8 animate-in fade-in duration-300">
<div>
<h3 className="text-lg font-bold text-gray-200 border-b border-slate-700 pb-2 mb-4">Identity & SSO</h3>
<div className="space-y-6">
<div className="bg-slate-900/40 p-5 rounded-2xl border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<p className="font-bold text-white uppercase text-xs tracking-widest">Microsoft Entra (Azure)</p>
</div>
<input type="checkbox" checked={settings.azure_enabled} onChange={e => setSettings({...settings, azure_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
{settings.azure_enabled && (
<div className="grid grid-cols-1 gap-4 pt-2">
<input placeholder="Tenant ID" value={settings.azure_tenant_id} onChange={e => setSettings({...settings, azure_tenant_id: e.target.value})} className="input-style" />
<input placeholder="Client ID" value={settings.azure_client_id} onChange={e => setSettings({...settings, azure_client_id: e.target.value})} className="input-style" />
<input type="password" placeholder="Client Secret" value={settings.azure_client_secret} onChange={e => setSettings({...settings, azure_client_secret: e.target.value})} className="input-style" />
</div>
)}
</div>
<div className="bg-slate-900/40 p-5 rounded-2xl border border-slate-700 space-y-4">
<div className="flex items-center justify-between">
<p className="font-bold text-white uppercase text-xs tracking-widest">LDAP / Active Directory</p>
<input type="checkbox" checked={settings.ldap_enabled} onChange={e => setSettings({...settings, ldap_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
{settings.ldap_enabled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<input placeholder="LDAP Host" value={settings.ldap_host} onChange={e => setSettings({...settings, ldap_host: e.target.value})} className="input-style" />
<input placeholder="Port" value={settings.ldap_port} onChange={e => setSettings({...settings, ldap_port: e.target.value})} className="input-style" />
<input placeholder="Base DN" value={settings.ldap_base_dn} onChange={e => setSettings({...settings, ldap_base_dn: e.target.value})} className="input-style md:col-span-2" />
<input placeholder="Bind DN" value={settings.ldap_bind_dn} onChange={e => setSettings({...settings, ldap_bind_dn: e.target.value})} className="input-style" />
<input type="password" placeholder="Bind Password" value={settings.ldap_bind_pass} onChange={e => setSettings({...settings, ldap_bind_pass: e.target.value})} className="input-style" />
<input placeholder="User Filter" value={settings.ldap_user_filter} onChange={e => setSettings({...settings, ldap_user_filter: e.target.value})} className="input-style md:col-span-2" />
</div>
)}
</div>
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-700">
<div><p className="font-bold text-white text-xs">Allow Local DB Login</p></div>
<input type="checkbox" checked={settings.allow_local_login} onChange={e => setSettings({...settings, allow_local_login: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" />
</div>
</div>
</div>
<div className="flex justify-end"><button type="submit" className="primary-btn">Save Identity Settings</button></div>
</form>
)}
{activeTab === 'users' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateOrUpdateUser} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="Username" required value={newUser.username} onChange={e => setNewUser({...newUser, username: e.target.value})} className="input-style" />
<input placeholder="Email" required type="email" value={newUser.email} onChange={e => setNewUser({...newUser, email: e.target.value})} className="input-style" />
<input placeholder={editingUserId ? "New Password (Optional)" : "Password"} required={!editingUserId} type="password" value={newUser.password} onChange={e => setNewUser({...newUser, password: e.target.value})} className="input-style" />
<div className="flex gap-2">
<select value={newUser.role} onChange={e => setNewUser({...newUser, role: e.target.value as any})} className="input-style flex-1"><option value="editor">Editor</option><option value="admin">Admin</option></select>
<button type="submit" className="primary-btn px-6">Save User</button>
</div>
</form>
<div className="rounded-xl border border-slate-700 overflow-hidden bg-slate-900/20">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">User</th><th className="p-4">Role</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{users.map(u => (
<tr key={u.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="p-4 text-white font-bold">{u.username}<br/><span className="text-[10px] font-normal text-slate-500">{u.email}</span></td>
<td className="p-4 uppercase text-[10px] font-black">{u.role}</td>
<td className="p-4 text-right flex justify-end gap-2">
<button onClick={() => { setEditingUserId(u.id); setNewUser({...newUser, username:u.username, email:u.email||'', role:u.role}); }} className="p-2 text-cyan-400 hover:bg-cyan-900/20 rounded"><EditIcon className="w-4 h-4"/></button>
{u.id !== 1 && <button onClick={() => deleteUser(u.id).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button>}
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'groups' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateGroup} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 flex flex-col md:flex-row gap-4">
<input placeholder="Group Name" required value={newGroup.groupName} onChange={e => setNewGroup({...newGroup, groupName: e.target.value})} className="input-style flex-1" />
<input placeholder="Emails (comma separated)" required value={newGroup.emails} onChange={e => setNewGroup({...newGroup, emails: e.target.value})} className="input-style flex-[2]" />
<button type="submit" className="primary-btn px-8">Add Group</button>
</form>
<div className="rounded-xl border border-slate-700 bg-slate-900/20">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">Name</th><th className="p-4">Recipients</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{groups.map(g => (
<tr key={g.id} className="border-b border-slate-700/50 hover:bg-slate-700/30"><td className="p-4 text-white font-bold">{g.groupName}</td><td className="p-4 text-xs font-mono text-slate-400 truncate max-w-md">{g.emails}</td><td className="p-4 text-right"><button onClick={() => deleteGroup(g.id).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button></td></tr>))}
</tbody></table>
</div>
</div>
)}
{activeTab === 'vendors' && (
<div className="space-y-6 animate-in fade-in duration-300">
<form onSubmit={handleCreateVendor} className="bg-slate-900/50 p-6 rounded-2xl border border-slate-700 grid grid-cols-1 md:grid-cols-2 gap-4">
<input placeholder="Vendor Name" required value={newVendor.vendorName} onChange={e => setNewVendor({...newVendor, vendorName: e.target.value})} className="input-style" />
<input placeholder="Contact Name" value={newVendor.contactName} onChange={e => setNewVendor({...newVendor, contactName: e.target.value})} className="input-style" />
<input placeholder="Contact Email" value={newVendor.contactEmail} onChange={e => setNewVendor({...newVendor, contactEmail: e.target.value})} className="input-style" />
<div className="flex gap-2">
<input placeholder="Contact Phone" value={newVendor.contactPhone} onChange={e => setNewVendor({...newVendor, contactPhone: e.target.value})} className="input-style flex-1" />
<button type="submit" className="primary-btn px-6">Save Vendor</button>
</div>
</form>
<div className="rounded-xl border border-slate-700 bg-slate-900/20 overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-900 text-slate-500 uppercase text-[10px] font-black"><tr><th className="p-4">Vendor</th><th className="p-4">Contact</th><th className="p-4">Email</th><th className="p-4 text-right">Actions</th></tr></thead>
<tbody>{contacts.map(c => (
<tr key={c.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="p-4 text-white font-bold">{c.vendorName}</td>
<td className="p-4 text-slate-300">{c.contactName || '---'}</td>
<td className="p-4 text-xs font-mono text-cyan-400">{c.contactEmail || '---'}</td>
<td className="p-4 text-right"><button onClick={() => deleteContact(c.id!).then(loadAllData)} className="p-2 text-red-400 hover:bg-red-900/20 rounded"><TrashIcon className="w-4 h-4"/></button></td>
</tr>))}
</tbody></table>
</div>
</div>
)}
</div>
<style>{`
.input-style { background: #1e293b; border: 1px solid #334155; color: #e5e7eb; border-radius: 12px; padding: 12px 16px; width: 100%; transition: all 0.2s; font-size: 0.875rem; }
.input-style:focus { outline: none; border-color: #22d3ee; box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.1); }
.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; }
.primary-btn { background: #0891b2; color: white; padding: 12px 24px; border-radius: 12px; font-weight: 700; transition: all 0.2s; border: none; cursor: pointer; font-size: 0.875rem; text-transform: uppercase; letter-spacing: 0.05em; }
.primary-btn:hover { background: #06b6d4; transform: translateY(-1px); }
.primary-btn:disabled { opacity: 0.5; cursor: not-allowed; }
`}</style>
</div>
);
};
export default AdminSettings;
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { AuditLog } from '../types';
import { fetchLogs } from '../graphService';
const AuditLogs: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const loadLogs = async () => {
setIsLoading(true);
try {
const data = await fetchLogs();
setLogs(Array.isArray(data) ? data : []);
setError('');
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadLogs();
}, []);
if (isLoading) return <div className="text-center text-cyan-400 py-16 font-mono text-sm animate-pulse tracking-widest">QUERYING SYSTEM JOURNALS...</div>;
if (error) return (
<div className="bg-red-900/20 border border-red-500/50 p-8 rounded-xl text-center">
<p className="text-red-400 font-mono text-xs mb-4">LOG_FETCH_FAILURE: {error}</p>
<button onClick={loadLogs} className="bg-red-600 text-white px-4 py-2 rounded-lg font-bold text-xs">Retry Connection</button>
</div>
);
return (
<div className="bg-slate-800 rounded-2xl shadow-xl overflow-hidden border border-slate-700 animate-in fade-in duration-500">
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
<div>
<h2 className="text-xl font-bold text-gray-200">System Audit Journal</h2>
<p className="text-[10px] text-slate-500 mt-1 uppercase font-black tracking-widest">Chronological Modification Tracking</p>
</div>
<button onClick={loadLogs} className="p-2 text-cyan-400 hover:bg-cyan-900/20 rounded-xl transition-all">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-slate-900 text-slate-500 uppercase text-[9px] font-black tracking-[0.2em]">
<tr>
<th className="px-6 py-4">Timestamp</th>
<th className="px-6 py-4">Identity</th>
<th className="px-6 py-4">Operation</th>
<th className="px-6 py-4">Object</th>
<th className="px-6 py-4">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-700/30 transition-colors text-[11px]">
<td className="px-6 py-4 whitespace-nowrap text-slate-500 font-mono">{new Date(log.created_at).toLocaleString()}</td>
<td className="px-6 py-4 font-bold text-white tracking-tight">{log.username}</td>
<td className="px-6 py-4">
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-tighter ${
log.action_type === 'CREATE' ? 'bg-green-900/40 text-green-400 border border-green-800/50' :
log.action_type === 'DELETE' ? 'bg-red-900/40 text-red-400 border border-red-800/50' :
log.action_type === 'LOGIN' ? 'bg-cyan-900/40 text-cyan-400 border border-cyan-800/50' :
'bg-blue-900/40 text-blue-400 border border-blue-800/50'
}`}>
{log.action_type}
</span>
</td>
<td className="px-6 py-4">
<span className="text-slate-500 uppercase text-[9px] font-black mr-2 opacity-50">{log.entity_type}</span>
<span className="text-gray-200">{log.entity_name}</span>
</td>
<td className="px-6 py-4 text-slate-400 italic max-w-xs truncate">{log.details}</td>
</tr>
))}
{logs.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-slate-600 font-medium">No system events logged in current period.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default AuditLogs;
View File
+118
View File
@@ -0,0 +1,118 @@
import React, { useMemo } from 'react';
import { License } from '../types';
import { BellIcon, ExclamationIcon, CalendarIcon } from './Icons';
interface DashboardAlertsProps {
licenses: License[];
alertDays: number;
}
const DashboardAlerts: React.FC<DashboardAlertsProps> = ({ licenses = [], alertDays = 45 }) => {
const alerts = useMemo(() => {
if (!Array.isArray(licenses)) return [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const futureThreshold = new Date(today);
futureThreshold.setDate(today.getDate() + alertDays);
const upcomingEvents: Array<{
id: string;
licenseName: string;
date: Date;
type: 'Renewal' | 'Expiration';
daysRemaining: number;
}> = [];
licenses.forEach(lic => {
if (!lic) return;
// Check Contract End Date
if (lic.endDate) {
const endDate = new Date(lic.endDate);
if (!isNaN(endDate.getTime())) {
endDate.setHours(0, 0, 0, 0);
if (endDate >= today && endDate <= futureThreshold) {
const diffTime = endDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
upcomingEvents.push({
id: lic.id,
licenseName: lic.licenseName,
date: endDate,
type: 'Expiration',
daysRemaining: diffDays
});
}
}
}
// Check Renewals
if (Array.isArray(lic.renewals)) {
lic.renewals.forEach(r => {
if (!r || !r.renewalDate) return;
const renewalDate = new Date(r.renewalDate);
if (!isNaN(renewalDate.getTime())) {
renewalDate.setHours(0, 0, 0, 0);
if (renewalDate >= today && renewalDate <= futureThreshold) {
const diffTime = renewalDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
upcomingEvents.push({
id: lic.id,
licenseName: lic.licenseName,
date: renewalDate,
type: 'Renewal',
daysRemaining: diffDays
});
}
}
});
}
});
return upcomingEvents.sort((a, b) => a.date.getTime() - b.date.getTime()).slice(0, 5);
}, [licenses, alertDays]);
if (alerts.length === 0) return null;
return (
<div className="bg-gradient-to-r from-slate-800 to-slate-900 rounded-lg shadow-lg mb-8 border-l-4 border-cyan-500 overflow-hidden">
<div className="p-4 sm:p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-gray-100 flex items-center">
<BellIcon className="w-5 h-5 text-cyan-400 mr-2" />
Upcoming Expirations & Renewals
</h2>
<span className="text-xs font-medium px-2 py-1 bg-cyan-900/50 text-cyan-300 rounded-full border border-cyan-700/50">
Next {alertDays} Days
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
{alerts.map((alert, idx) => (
<div key={`${alert.id}-${idx}`} className="bg-slate-800/50 border border-slate-700 rounded p-3 flex flex-col justify-between hover:bg-slate-700/50 transition-colors group">
<div className="flex justify-between items-start mb-2">
<div className={`p-1.5 rounded-md ${alert.daysRemaining < 7 ? 'bg-red-900/30 text-red-400' : 'bg-cyan-900/30 text-cyan-400'}`}>
{alert.type === 'Expiration' ? <ExclamationIcon className="w-4 h-4" /> : <CalendarIcon className="w-4 h-4" />}
</div>
<span className={`text-xs font-bold ${alert.daysRemaining < 7 ? 'text-red-400' : 'text-cyan-400'}`}>
{alert.daysRemaining === 0 ? 'Today' : `${alert.daysRemaining} days`}
</span>
</div>
<div>
<h4 className="font-semibold text-gray-200 text-sm truncate" title={alert.licenseName}>{alert.licenseName}</h4>
<p className="text-xs text-gray-400 mt-1">{alert.type}</p>
<p className="text-xs text-gray-500">{alert.date.toLocaleDateString()}</p>
</div>
</div>
))}
</div>
</div>
</div>
);
};
export default DashboardAlerts;
+83
View File
@@ -0,0 +1,83 @@
import React, { ErrorInfo, ReactNode } from 'react';
interface ErrorBoundaryProps {
children?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
/**
* Global Error Boundary to catch UI crashes and display a fallback screen.
*/
// Use React.Component explicitly to ensure proper inheritance and property access for props and state
export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
// Explicitly define properties for TypeScript to recognize them on the class instance
public state: ErrorBoundaryState;
public props: ErrorBoundaryProps;
constructor(props: ErrorBoundaryProps) {
super(props);
// Initialize state in constructor to follow standard React patterns and avoid property conflicts
this.state = {
hasError: false,
error: null
};
this.props = props;
}
public static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught license-tracker error:", error, errorInfo);
}
public render(): ReactNode {
// Destructuring state and props from this. Inheritance from Component ensures they exist.
// Explicit usage of this.state and this.props helps avoid shadowing or scoping issues.
const { hasError, error } = this.state;
const { children } = this.props;
if (hasError) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4 text-gray-200">
<div className="bg-slate-800 border border-red-500/50 p-6 rounded-lg max-w-2xl shadow-2xl w-full">
<div className="flex items-center gap-3 mb-4">
<div className="bg-red-500/20 p-2 rounded-full">
<svg className="w-6 h-6 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-red-400">Application Failure</h1>
</div>
<p className="mb-4 text-gray-300">The interface encountered an unexpected error during rendering.</p>
<div className="bg-black/40 p-4 rounded text-xs font-mono text-red-300 overflow-auto max-h-64 mb-6 border border-slate-700 whitespace-pre-wrap">
{error?.stack || error?.toString()}
</div>
<div className="flex gap-4">
<button
onClick={() => window.location.reload()}
className="flex-1 px-6 py-3 bg-cyan-600 hover:bg-cyan-500 text-white font-bold rounded-xl transition-all shadow-lg active:scale-95"
>
Reload Interface
</button>
<button
onClick={() => { localStorage.clear(); window.location.reload(); }}
className="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-slate-300 font-bold rounded-xl transition-all"
>
Clear Cache & Reset
</button>
</div>
</div>
</div>
);
}
return children || null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useRef } from 'react';
import { ArrowDownIcon, ArrowUpIcon, UserIcon, SettingsIcon } from './Icons';
import { User } from '../types';
interface HeaderProps {
user: User | null;
onLogout: () => void;
onLoginClick: () => void;
onImport: (e: React.ChangeEvent<HTMLInputElement>) => void;
onExport: () => void;
currentView: string;
onChangeView: (view: string) => void;
}
const Header: React.FC<HeaderProps> = ({ user, onLogout, onLoginClick, onImport, onExport, currentView, onChangeView }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<header className="mb-8 flex flex-col md:flex-row justify-between items-center border-b border-slate-700 pb-4 gap-4">
<div className="cursor-pointer" onClick={() => onChangeView('dashboard')}>
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-cyan-500">
License Tracker
</h1>
<p className="text-gray-400 mt-1 text-sm">
{user ? `Welcome, ${user.username} (${user.role})` : 'Read-Only Mode'}
</p>
</div>
<div className="flex flex-wrap justify-center items-center gap-3">
{/* Nav Links */}
<div className="flex space-x-1 mr-2">
<button
onClick={() => onChangeView('dashboard')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${currentView === 'dashboard' ? 'text-cyan-400 bg-slate-800' : 'text-gray-400 hover:text-gray-200'}`}
>
Dashboard
</button>
{user && user.role === 'admin' && (
<>
<button
onClick={() => onChangeView('logs')}
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${currentView === 'logs' ? 'text-cyan-400 bg-slate-800' : 'text-gray-400 hover:text-gray-200'}`}
>
Logs
</button>
<button
onClick={() => onChangeView('settings')}
className={`flex items-center px-3 py-1.5 rounded text-sm font-medium transition-colors ${currentView === 'settings' ? 'text-cyan-400 bg-slate-800' : 'text-gray-400 hover:text-gray-200'}`}
>
<SettingsIcon className="w-4 h-4 mr-1" />
Settings
</button>
</>
)}
</div>
{/* Tools (Backup/Restore) - Restricted to Admins */}
{user && user.role === 'admin' && (
<div className="flex bg-slate-800 rounded-md p-1 border border-slate-600">
<button
onClick={onExport}
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-cyan-400 hover:bg-slate-700 rounded transition-colors"
title="Download Database Backup"
>
<ArrowDownIcon className="w-4 h-4" />
<span>Backup</span>
</button>
<div className="w-px bg-slate-600 mx-1 my-1"></div>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-cyan-400 hover:bg-slate-700 rounded transition-colors"
title="Restore Database from File"
>
<ArrowUpIcon className="w-4 h-4" />
<span>Restore</span>
</button>
<input
type="file"
ref={fileInputRef}
onChange={onImport}
accept=".json"
className="hidden"
/>
</div>
)}
{/* Auth Button */}
{user ? (
<button onClick={onLogout} className="px-4 py-2 rounded-md bg-slate-700 hover:bg-slate-600 font-semibold text-gray-200 transition-colors text-sm border border-slate-600">
Logout
</button>
) : (
<button onClick={onLoginClick} className="flex items-center space-x-2 px-4 py-2 rounded-md bg-cyan-600 hover:bg-cyan-500 font-semibold text-white transition-colors text-sm shadow-lg shadow-cyan-500/20">
<UserIcon className="w-4 h-4" />
<span>Login</span>
</button>
)}
</div>
</header>
);
};
export default Header;
View File
+122
View File
@@ -0,0 +1,122 @@
import React from 'react';
interface IconProps {
className?: string;
}
export const PlusIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
);
export const EditIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z" />
</svg>
);
export const TrashIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
export const CalendarIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
);
export const PriceIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
export const UserIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
);
export const ChevronLeftIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
);
export const ChevronRightIcon: React.FC<IconProps> = ({ className = 'w-6 h-6' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
);
export const ArrowUpIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 11l5-5m0 0l5 5m-5-5v12" />
</svg>
);
export const ArrowDownIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M17 13l-5 5m0 0l-5-5m5 5V6" />
</svg>
);
export const MinusIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12H4" />
</svg>
);
export const BriefcaseIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
);
export const SaveIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 3.75V16.5L12 14.25 7.5 16.5V3.75m9 0H18A2.25 2.25 0 0120.25 6v12A2.25 2.25 0 0118 20.25H6A2.25 2.25 0 013.75 18V6A2.25 2.25 0 016 3.75h1.5m9 0h-9" />
</svg>
);
export const BellIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
</svg>
);
export const ExclamationIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
);
export const SettingsIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.212 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export const ArchiveIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5m8.25 3v6.75m0 0l-3-3m3 3l3-3M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
</svg>
);
export const EyeIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export const EyeOffIcon: React.FC<IconProps> = ({ className = 'w-5 h-5' }) => (
<svg xmlns="http://www.w3.org/2000/svg" className={className} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
View File
+317
View File
@@ -0,0 +1,317 @@
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;
View File
+149
View File
@@ -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;
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { License } from '../types';
import LicenseItem from './LicenseItem';
interface LicenseListProps {
licenses: License[];
onEdit?: (license: License) => void;
onDelete?: (id: string) => void;
isReadOnly?: boolean;
}
const LicenseList: React.FC<LicenseListProps> = ({ licenses, onEdit, onDelete, isReadOnly }) => {
if (!licenses || licenses.length === 0) {
return (
<div className="text-center py-16 px-6 bg-slate-800 rounded-lg shadow-inner border border-slate-700">
<h3 className="text-2xl font-bold text-gray-300">No licenses found.</h3>
{!isReadOnly && <p className="text-gray-400 mt-2">Click "Add New License" to get started.</p>}
</div>
);
}
return (
<div className="space-y-6">
{licenses.map(license => (
<LicenseItem
key={license.id}
license={license}
onEdit={onEdit}
onDelete={onDelete}
isReadOnly={isReadOnly}
/>
))}
</div>
);
};
export default LicenseList;
+130
View File
@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { login, getAzureLoginUrl } from '../graphService';
import { User, AppSettings } from '../types';
interface LoginFormProps {
settings: AppSettings;
onLoginSuccess: (user: User) => void;
onCancel: () => void;
}
const LoginForm: React.FC<LoginFormProps> = ({ settings, onLoginSuccess, onCancel }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [authSource, setAuthSource] = useState<'local' | 'ldap'>('local');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!username || !password) {
setError("Username and password are required.");
return;
}
setIsLoading(true);
setError('');
try {
const data = await login(username, password, authSource);
console.log("[LOGIN DEBUG] Server Response:", data);
if (data && (data.user || data.success === true)) {
// PHP might return {success: true, user: {...}} or just {user: {...}}
const userObj = data.user || data;
onLoginSuccess(userObj as User);
} else {
setError(data?.error || "Authentication failed: No user data returned.");
}
} catch (err: any) {
console.error("[LOGIN ERROR]", err);
setError(err.message || 'The server rejected these credentials.');
} finally {
setIsLoading(false);
}
};
const handleAzureLogin = async () => {
setIsLoading(true);
try {
const { url } = await getAzureLoginUrl();
window.location.href = url;
} catch (err: any) {
setError(err.message);
setIsLoading(false);
}
};
// Safety check for settings object
if (!settings) return null;
return (
<div className="fixed inset-0 bg-slate-950/80 flex justify-center items-center z-50 p-4 backdrop-blur-sm">
<div className="bg-slate-900 p-8 rounded-2xl shadow-2xl w-full max-w-sm border border-slate-800 animate-in fade-in zoom-in duration-200">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-1">
System Login
</h2>
<p className="text-xs text-slate-500">Secure License Repository</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/50 text-red-400 text-[10px] font-mono p-3 rounded-lg mb-6 text-center break-words">
{error}
</div>
)}
<div className="space-y-4">
{settings?.azure_enabled && (
<button
onClick={handleAzureLogin}
disabled={isLoading}
className="w-full flex items-center justify-center gap-3 bg-white hover:bg-gray-100 text-slate-900 font-bold py-2.5 px-4 rounded-xl transition-all disabled:opacity-50"
>
<svg className="w-4 h-4" viewBox="0 0 21 21" xmlns="http://www.w3.org/2000/svg"><path d="m0 0h10.1v10.1h-10.1z" fill="#f35325"/><path d="m10.9 0h10.1v10.1h-10.1z" fill="#81bc06"/><path d="m0 10.9h10.1v10.1h-10.1z" fill="#05a6f0"/><path d="m10.9 10.9h10.1v10.1h-10.1z" fill="#ffba08"/></svg>
Entra ID SSO
</button>
)}
<form onSubmit={handleLogin} className="space-y-4">
{settings?.ldap_enabled && (
<div className="grid grid-cols-2 gap-1 p-1 bg-slate-950 rounded-lg border border-slate-800">
<button type="button" onClick={() => setAuthSource('local')} className={`py-1.5 text-xs font-bold rounded-md transition-all ${authSource === 'local' ? 'bg-slate-800 text-cyan-400' : 'text-slate-500'}`}>Local</button>
<button type="button" onClick={() => setAuthSource('ldap')} className={`py-1.5 text-xs font-bold rounded-md transition-all ${authSource === 'ldap' ? 'bg-slate-800 text-cyan-400' : 'text-slate-500'}`}>LDAP</button>
</div>
)}
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1 ml-1 tracking-widest">Username</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-sm text-gray-200 focus:outline-none focus:border-cyan-500 transition-all shadow-inner"
placeholder="Username"
autoFocus
/>
</div>
<div>
<label className="block text-[10px] font-bold text-slate-500 uppercase mb-1 ml-1 tracking-widest">Password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-950 border border-slate-800 rounded-xl p-3 text-sm text-gray-200 focus:outline-none focus:border-cyan-500 transition-all shadow-inner"
placeholder="••••••••"
/>
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={onCancel} className="flex-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm font-bold py-3 rounded-xl border border-slate-700 transition-all">Cancel</button>
<button type="submit" disabled={isLoading} className="flex-1 bg-cyan-600 hover:bg-cyan-500 text-white text-sm font-bold py-3 rounded-xl transition-all shadow-lg active:scale-95 disabled:opacity-50">
{isLoading ? 'Authenticating...' : 'Sign In'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default LoginForm;
View File
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { License } from '../types';
interface PriceHistoryChartProps {
license: License;
}
const formatCurrency = (value: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 }).format(value);
const PriceHistoryChart: React.FC<PriceHistoryChartProps> = ({ license }) => {
const data = [
{ date: new Date(license.purchaseDate), price: license.purchasePrice },
...(license.renewals || []).map(r => ({ date: new Date(r.renewalDate), price: r.renewalPrice }))
]
.map(i => ({ ...i, time: i.date.getTime(), str: i.date.toLocaleDateString() }))
.filter(i => !isNaN(i.time))
.sort((a, b) => a.time - b.time);
if (data.length < 2) {
return <div className="flex items-center justify-center h-32 text-xs text-gray-500 bg-slate-900/30 rounded">No price history</div>;
}
return (
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="time" type="number" domain={['dataMin', 'dataMax']} tickFormatter={(t) => new Date(t).toLocaleDateString(undefined, {month:'short', year:'2-digit'})} stroke="#9ca3af" tick={{fontSize: 10}} />
<YAxis stroke="#9ca3af" tick={{fontSize: 10}} tickFormatter={(v) => `$${v}`} width={40} />
<Tooltip
contentStyle={{ backgroundColor: '#1e293b', borderColor: '#475569', color: '#f1f5f9' }}
labelFormatter={(l) => new Date(l).toLocaleDateString()}
formatter={(val: number) => [formatCurrency(val), 'Price']}
/>
<Line type="monotone" dataKey="price" stroke="#22d3ee" strokeWidth={2} dot={{r:3}} activeDot={{r:5}} />
</LineChart>
</ResponsiveContainer>
);
};
export default PriceHistoryChart;
+74
View File
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { resetPassword } from '../graphService';
interface ResetPasswordProps {
token: string;
onSuccess: () => void;
}
const ResetPassword: React.FC<ResetPasswordProps> = ({ token, onSuccess }) => {
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters.");
return;
}
setLoading(true);
setError('');
try {
await resetPassword(token, password);
alert("Password reset successfully. You can now login.");
onSuccess();
} catch (e: any) {
setError(e.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center p-4">
<div className="bg-slate-800 p-8 rounded-lg shadow-2xl w-full max-w-md border border-slate-700">
<h2 className="text-3xl font-bold text-cyan-400 mb-6 text-center">Set New Password</h2>
<form onSubmit={handleSubmit} className="space-y-6">
{error && <div className="bg-red-900/50 border border-red-500 text-red-200 text-sm p-3 rounded">{error}</div>}
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">New Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md p-2.5 text-gray-200 focus:outline-none focus:border-cyan-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-400 mb-1">Confirm Password</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-md p-2.5 text-gray-200 focus:outline-none focus:border-cyan-500"
/>
</div>
<button type="submit" disabled={loading} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold py-3 rounded-md transition-colors disabled:opacity-50">
{loading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
};
export default ResetPassword;
View File
+175
View File
@@ -0,0 +1,175 @@
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;
+279
View File
@@ -0,0 +1,279 @@
import React, { useEffect, useState } from 'react';
import { User } from '../types';
import { fetchUsers, createUser, deleteUser, updateUser } from '../graphService';
import { TrashIcon, PlusIcon, UserIcon, EditIcon, SettingsIcon } from './Icons';
const UserManagement: React.FC = () => {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [showDBHelper, setShowDBHelper] = useState(false);
// Form State
const [formData, setFormData] = useState({
username: '',
password: '',
email: '',
role: 'editor' as 'admin'|'editor',
auth_source: 'local' as 'local'|'azure'|'ldap',
sendEmail: true
});
const [isFormVisible, setIsFormVisible] = useState(false);
const [editingUserId, setEditingUserId] = useState<number | null>(null);
const loadUsers = async () => {
setIsLoading(true);
try {
const data = await fetchUsers();
setUsers(data);
setError('');
} catch (err: any) {
setError(err.message);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const handleCreateOrUpdate = async (e: React.FormEvent) => {
e.preventDefault();
try {
const payload = { ...formData };
// If editing, only send password if it has been typed
if (editingUserId) {
if (!payload.password) {
delete (payload as any).password;
}
await updateUser(editingUserId, payload);
} else {
await createUser(payload);
}
resetForm();
loadUsers();
} catch (err: any) {
alert('Operation Failed: ' + err.message);
}
};
const resetForm = () => {
setFormData({ username: '', password: '', email: '', role: 'editor', auth_source: 'local', sendEmail: true });
setIsFormVisible(false);
setEditingUserId(null);
};
const handleEdit = (user: User) => {
setEditingUserId(user.id);
setFormData({
username: user.username,
password: '',
email: user.email || '',
role: user.role,
auth_source: user.auth_source || 'local',
sendEmail: false
});
setIsFormVisible(true);
};
const handleDelete = async (id: number) => {
if (id === 1) return;
if (!window.confirm('Are you sure you want to delete this user?')) return;
try {
await deleteUser(id);
loadUsers();
} catch (err: any) {
alert('Error deleting user: ' + err.message);
}
};
if (isLoading && users.length === 0) return <div className="text-center py-8 text-cyan-400 font-mono text-sm animate-pulse">Establishing Secure DB Connection...</div>;
return (
<div className="space-y-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 bg-slate-800 rounded-lg shadow-lg overflow-hidden h-fit border border-slate-700">
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
<div>
<h2 className="text-xl font-bold text-gray-200">System Accounts</h2>
<p className="text-sm text-gray-500 font-mono text-[10px] uppercase tracking-wider">MariaDB Production Instance</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowDBHelper(!showDBHelper)}
className={`px-3 py-2 rounded flex items-center text-sm font-bold transition-all border ${showDBHelper ? 'bg-red-600 text-white border-red-500' : 'bg-slate-700 hover:bg-slate-600 text-slate-300 border-slate-600'}`}
>
<SettingsIcon className="w-4 h-4 mr-2"/> {showDBHelper ? 'Close Fix' : 'Fix Login Error'}
</button>
{!isFormVisible && (
<button
onClick={() => setIsFormVisible(true)}
className="bg-cyan-600 hover:bg-cyan-500 text-white px-3 py-2 rounded flex items-center text-sm font-bold shadow-lg transition-all active:scale-95"
>
<PlusIcon className="w-4 h-4 mr-2"/> Add User
</button>
)}
</div>
</div>
{error && <div className="p-4 bg-red-900/20 text-red-400 border-b border-red-900/50 text-xs font-mono">Error: {error}</div>}
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-gray-400">
<thead className="bg-slate-900/80 uppercase text-[10px] font-black tracking-widest">
<tr>
<th className="px-6 py-3">Username</th>
<th className="px-6 py-3">Privilege</th>
<th className="px-6 py-3">Source</th>
<th className="px-6 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700">
{users.map(user => (
<tr key={user.id} className="hover:bg-slate-700/50 transition-colors">
<td className="px-6 py-4 font-medium text-white flex items-center">
<UserIcon className="w-4 h-4 mr-2 text-gray-500"/>
{user.username}
{user.id === 1 && <span className="ml-2 text-[8px] bg-cyan-900/40 text-cyan-400 border border-cyan-800/50 px-1.5 py-0.5 rounded font-black tracking-tighter">PRIMARY ADMIN</span>}
</td>
<td className="px-6 py-4">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${user.role === 'admin' ? 'bg-purple-900/40 text-purple-400' : 'bg-slate-900/60 text-slate-400'}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 font-mono text-xs uppercase tracking-tighter text-slate-500">{user.auth_source}</td>
<td className="px-6 py-4 text-right flex justify-end gap-2">
<button onClick={() => handleEdit(user)} className="text-cyan-400 hover:text-cyan-300 p-2 hover:bg-cyan-900/20 rounded-full transition-all" title="Modify Credentials">
<EditIcon className="w-4 h-4"/>
</button>
{user.id !== 1 && (
<button onClick={() => handleDelete(user.id)} className="text-red-400 hover:text-red-300 p-2 hover:bg-red-900/20 rounded-full transition-all" title="Remove Account">
<TrashIcon className="w-4 h-4"/>
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{isFormVisible && (
<div className="lg:col-span-1 bg-slate-800 rounded-lg shadow-lg p-6 h-fit border border-slate-700 animate-in slide-in-from-right duration-300">
<div className="flex justify-between items-center mb-6 border-b border-slate-700 pb-2">
<h3 className="text-lg font-bold text-cyan-400">
{editingUserId ? 'Edit Account' : 'New Account'}
</h3>
<button onClick={resetForm} className="text-gray-500 hover:text-gray-300 text-xl">&times;</button>
</div>
<form onSubmit={handleCreateOrUpdate} className="space-y-4">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1 ml-1">Username</label>
<input
required
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-gray-200 focus:outline-none focus:border-cyan-500 transition-all"
value={formData.username}
onChange={e => setFormData({...formData, username: e.target.value})}
/>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1 ml-1">Email</label>
<input
type="email"
required
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-gray-200 focus:outline-none focus:border-cyan-500 transition-all"
value={formData.email}
onChange={e => setFormData({...formData, email: e.target.value})}
/>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1 ml-1">
{editingUserId ? 'New Password (Blank to Keep)' : 'Password'}
</label>
<input
required={!editingUserId}
type="password"
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-gray-200 focus:outline-none focus:border-cyan-500 transition-all"
value={formData.password}
onChange={e => setFormData({...formData, password: e.target.value})}
placeholder={editingUserId ? "••••••••" : ""}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1 ml-1">Role</label>
<select
disabled={editingUserId === 1}
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-gray-200 focus:outline-none focus:border-cyan-500 transition-all disabled:opacity-50"
value={formData.role}
onChange={e => setFormData({...formData, role: e.target.value as any})}
>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label className="block text-[10px] font-black text-gray-500 uppercase tracking-widest mb-1 ml-1">Auth Source</label>
<select
className="w-full bg-slate-900 border border-slate-700 rounded-xl p-3 text-gray-200 focus:outline-none focus:border-cyan-500 transition-all"
value={formData.auth_source}
onChange={e => setFormData({...formData, auth_source: e.target.value as any})}
>
<option value="local">Local</option>
<option value="ldap">LDAP</option>
<option value="azure">Azure</option>
</select>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={resetForm} className="flex-1 py-3 bg-slate-700/50 hover:bg-slate-700 text-gray-400 font-bold rounded-xl transition-all border border-slate-600">Cancel</button>
<button type="submit" className="flex-1 py-3 bg-cyan-600 hover:bg-cyan-500 text-white rounded-xl font-bold transition-all shadow-lg active:scale-95">
{editingUserId ? 'Apply Changes' : 'Create User'}
</button>
</div>
</form>
</div>
)}
</div>
{showDBHelper && (
<div className="bg-slate-900 border border-red-900/50 p-6 rounded-lg animate-in fade-in slide-in-from-bottom duration-300">
<h3 className="text-red-400 font-bold mb-2 flex items-center">
<SettingsIcon className="w-5 h-5 mr-2"/> Fix: Apply Verified Working Hash
</h3>
<p className="text-xs text-gray-400 mb-4 leading-relaxed">
Use the exact Blowfish hash you verified for the 'admin' user recovery.
<br/><br/>
<b>Confirmed Hash for 'admin123':</b>
</p>
<div className="space-y-4">
<div>
<p className="text-[10px] font-black text-gray-500 uppercase mb-1">Step 1: Open Terminal</p>
<code className="block bg-black p-3 rounded border border-slate-700 text-green-400 text-xs">
docker compose exec db mariadb -u root -p
</code>
</div>
<div>
<p className="text-[10px] font-black text-gray-500 uppercase mb-1">Step 2: Update Password Field</p>
<code className="block bg-black p-3 rounded border border-slate-700 text-cyan-400 text-xs whitespace-pre-wrap break-all">
{`USE license_tracker;
-- Apply the verified Blowfish hash ($2a$ format) for password 'admin123':
UPDATE users SET password = '$2a$12$.UrnKUhUx9ypgARYW7VF2.okk.PyKqzSlCflAi5aDKhSrbKU.ciii' WHERE username = 'admin';
FLUSH PRIVILEGES;
EXIT;`}
</code>
</div>
</div>
</div>
)}
</div>
);
};
export default UserManagement;
View File
View File
+44
View File
@@ -0,0 +1,44 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8081:80"
environment:
- DB_HOST=db
- DB_NAME=license_tracker
- DB_USER=root
- DB_PASS=root_password
depends_on:
db:
condition: service_healthy
restart: always
networks:
- app-network
db:
build:
context: .
dockerfile: Dockerfile.db
restart: always
environment:
- MYSQL_ROOT_PASSWORD=root_password
- MYSQL_DATABASE=license_tracker
# We remove the volume mount for the SQL script because it's now in the Dockerfile
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot_password"]
interval: 5s
timeout: 5s
retries: 5
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
db_data:
View File
+99
View File
@@ -0,0 +1,99 @@
import { License, User, AuditLog, Contact, NotificationGroup, AppSettings, AIInsight } from './types';
import { GoogleGenAI, Type } from "@google/genai";
const API_URL = 'api.php';
export const DEFAULT_SETTINGS: AppSettings = {
fiscal_start_month: 4, alert_days: 45, smtp_host: '', smtp_port: '465',
smtp_user: '', smtp_pass: '', smtp_from: '', azure_enabled: false,
azure_tenant_id: '', azure_client_id: '', azure_client_secret: '',
ldap_enabled: false, ldap_host: '', ldap_port: '389', ldap_base_dn: '',
ldap_bind_dn: '', ldap_bind_pass: '', ldap_user_filter: '', allow_local_login: true
};
async function handleResponse(response: Response) {
const text = await response.text();
let json;
try {
json = text ? JSON.parse(text) : null;
} catch(e) {
console.error("Failed to parse JSON response. Status:", response.status, "Text:", text.substring(0, 100));
json = null;
}
if (!response.ok) {
const errorMsg = json?.error || `Server Error (${response.status}): ${text.substring(0, 50)}`;
throw new Error(errorMsg);
}
return json;
}
export const checkSession = async () => handleResponse(await fetch(`${API_URL}?action=check_session`));
export const login = async (username: string, password: string, authSource: string = 'local') =>
handleResponse(await fetch(`${API_URL}?action=login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password, authSource })
}));
export const logout = async () => handleResponse(await fetch(`${API_URL}?action=logout`));
export const fetchLicenses = async () => handleResponse(await fetch(`${API_URL}?action=list`));
export const fetchSettings = async () => handleResponse(await fetch(`${API_URL}?action=settings_get`));
export const saveLicense = async (license: any) => {
return handleResponse(await fetch(`${API_URL}?action=save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(license)
}));
};
export const deleteLicense = async (id: string) => handleResponse(await fetch(`${API_URL}?action=delete&id=${id}`, { method: 'POST' }));
export const saveSettings = async (settings: AppSettings) => handleResponse(await fetch(`${API_URL}?action=settings_save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }));
export const fetchUsers = async () => handleResponse(await fetch(`${API_URL}?action=users_list`));
export const createUser = async (u: any) => handleResponse(await fetch(`${API_URL}?action=users_create`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(u) }));
export const updateUser = async (id: number, u: any) => handleResponse(await fetch(`${API_URL}?action=users_update&id=${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(u) }));
export const deleteUser = async (id: number) => handleResponse(await fetch(`${API_URL}?action=users_delete&id=${id}`, { method: 'POST' }));
export const fetchGroups = async () => handleResponse(await fetch(`${API_URL}?action=groups_list`));
export const saveGroup = async (g: any) => handleResponse(await fetch(`${API_URL}?action=groups_save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(g) }));
export const deleteGroup = async (id: number) => handleResponse(await fetch(`${API_URL}?action=groups_delete&id=${id}`, { method: 'POST' }));
export const fetchContacts = async () => handleResponse(await fetch(`${API_URL}?action=contacts_list`));
export const saveContact = async (c: any) => handleResponse(await fetch(`${API_URL}?action=contacts_save`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(c) }));
export const deleteContact = async (id: number) => handleResponse(await fetch(`${API_URL}?action=contacts_delete&id=${id}`, { method: 'POST' }));
export const fetchLogs = async () => handleResponse(await fetch(`${API_URL}?action=logs_list`));
export async function getAIInsights(license: License): Promise<AIInsight> {
try {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
const res = await ai.models.generateContent({
model: 'gemini-3-pro-preview',
contents: `Analyze this license for procurement risks: ${license.licenseName}, Vendor: ${license.vendorName}. Return JSON with status (saving/warning/neutral), advice, and suggested_alternatives.`,
config: {
responseMimeType: 'application/json',
responseSchema: {
type: Type.OBJECT,
properties: {
status: { type: Type.STRING },
advice: { type: Type.STRING },
suggested_alternatives: { type: Type.ARRAY, items: { type: Type.STRING } }
},
required: ["status", "advice", "suggested_alternatives"]
}
}
});
return JSON.parse(res.text || '{}');
} catch (e) {
return { status: 'neutral', advice: "Advice unavailable.", suggested_alternatives: [] };
}
}
export const deleteFile = async (id: number) => handleResponse(await fetch(`${API_URL}?action=file_delete&id=${id}`, { method: 'POST' }));
export const exportDatabase = async () => handleResponse(await fetch(`${API_URL}?action=export`));
export const importDatabase = async (json: string) => handleResponse(await fetch(`${API_URL}?action=import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: json }));
export const sendTestEmail = async (email: string) => handleResponse(await fetch(`${API_URL}?action=test_email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }) }));
export const runNotifications = async () => handleResponse(await fetch(`${API_URL}?action=run_notifications`));
export const getAzureLoginUrl = async () => ({ url: '#' });
export const resetPassword = async (token: string, password: string) => handleResponse(await fetch(`${API_URL}?action=reset_password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }) }));
+330
View File
@@ -0,0 +1,330 @@
/* General Body & Theme */
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background-color: #0f172a; /* slate-900 */
color: #e5e7eb; /* gray-200 */
margin: 0;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid #334155; /* slate-700 */
}
header h1 {
font-size: 2.5rem;
color: #22d3ee; /* cyan-400 */
margin: 0;
}
header span {
margin-right: 1rem;
color: #9ca3af; /* gray-400 */
}
/* Buttons */
.button {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
transition: background-color 0.2s ease;
border: none;
cursor: pointer;
background-color: #334155; /* slate-700 */
color: #e5e7eb; /* gray-200 */
}
.button:hover {
background-color: #475569; /* slate-600 */
}
.primary-button {
background-color: #06b6d4; /* cyan-500 */
color: #0f172a; /* slate-900 */
}
.primary-button:hover {
background-color: #22d3ee; /* cyan-400 */
}
.delete-button {
background-color: #ef4444;
color: white;
}
.delete-button:hover {
background-color: #f87171;
}
/* Cards */
.card {
background-color: #1e293b; /* slate-800 */
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-header h2 {
margin: 0;
font-size: 1.25rem;
}
.nav-button {
display: inline-block;
padding: 0.25rem 0.5rem;
text-decoration: none;
color: #9ca3af;
background-color: #334155;
border-radius: 4px;
margin-left: 0.5rem;
}
/* Timeline */
.timeline-container {
padding: 1rem 0;
}
.timeline-bar {
position: relative;
height: 8px;
background-color: #334155;
border-radius: 4px;
}
.timeline-dot-wrapper {
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.timeline-dot {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #1e293b;
transform: translateX(-50%);
cursor: pointer;
}
.timeline-tooltip {
visibility: hidden;
width: max-content;
background-color: #0f172a;
color: #fff;
text-align: center;
border-radius: 6px;
padding: 5px 10px;
position: absolute;
z-index: 1;
bottom: 150%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
}
.timeline-dot-wrapper:hover .timeline-tooltip {
visibility: visible;
opacity: 1;
}
.timeline-legend {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
font-size: 0.875rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.legend-color-box {
width: 14px;
height: 14px;
border-radius: 3px;
}
/* License List */
.actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.license-item .license-main {
display: grid;
grid-template-columns: 150px 1fr 300px;
gap: 1.5rem;
align-items: start;
}
.license-image img {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #334155;
}
.license-details h3 {
font-size: 1.75rem;
color: #22d3ee;
margin-top: 0;
margin-bottom: 0.5rem;
}
.license-details p {
margin: 0.25rem 0;
color: #9ca3af;
}
.license-details p strong {
color: #e5e7eb;
}
.comment-box {
background-color: #334155;
padding: 0.5rem;
border-radius: 4px;
margin-top: 1rem;
font-size: 0.9rem;
}
.license-chart {
height: 150px;
}
.license-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #334155;
text-align: right;
}
.license-actions .button {
margin-left: 0.5rem;
}
/* Forms */
.form-container h3 {
margin-top: 2rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #334155;
color: #22d3ee;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group.form-group-full {
grid-column: 1 / -1;
}
.form-group label {
margin-bottom: 0.5rem;
font-weight: 500;
color: #9ca3af;
}
input[type="text"], input[type="email"], input[type="tel"], input[type="date"], input[type="number"], textarea {
background: #0f172a;
border: 1px solid #334155;
color: #e5e7eb;
border-radius: 6px;
padding: 0.75rem;
width: 100%;
box-sizing: border-box;
}
input:focus, textarea:focus {
outline: none;
border-color: #22d3ee;
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.5);
}
textarea {
resize: vertical;
}
.form-image-preview {
max-width: 100px;
margin-top: 1rem;
border-radius: 6px;
}
.form-actions {
text-align: right;
margin-top: 2rem;
border-top: 1px solid #334155;
padding-top: 1.5rem;
}
.form-actions .button {
margin-left: 1rem;
}
#renewals-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.renewal-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr auto;
gap: 1rem;
align-items: center;
}
/* Login Page */
.login-body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background-color: #1e293b;
border-radius: 8px;
box-shadow: 0 10px 15px rgba(0,0,0,0.2);
}
.login-title {
text-align: center;
font-size: 2rem;
color: #22d3ee;
margin-bottom: 2rem;
}
.login-form div {
margin-bottom: 1rem;
}
.login-form label {
display: block;
margin-bottom: 0.5rem;
}
.login-form input {
width: 100%;
padding: 0.75rem;
}
.login-form button {
width: 100%;
padding: 0.75rem;
margin-top: 1rem;
font-size: 1rem;
}
.login-footer {
text-align: center;
margin-top: 1rem;
font-size: 0.9rem;
color: #9ca3af;
}
.error-message {
background-color: rgba(239, 68, 68, 0.2);
color: #f87171;
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
border: 1px solid #ef4444;
}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>License Tracker Pro</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
'slate': { 700: '#334155', 800: '#1e293b', 900: '#0f172a' },
'cyan': { 400: '#22d3ee', 500: '#06b6d4' }
}
}
}
}
</script>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@^19.2.3",
"react/": "https://esm.sh/react@^19.2.3/",
"recharts": "https://esm.sh/recharts@^3.6.0",
"@google/genai": "https://esm.sh/@google/genai@^1.34.0",
"vite": "https://esm.sh/vite@^7.3.0",
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/"
}
}
</script>
</head>
<body class="bg-slate-900">
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
View File
+2
View File
@@ -0,0 +1,2 @@
// This file acts as a secondary entry point. The main logic is in main.tsx as referenced by index.html.
import './main';
View File
View File
View File
+19
View File
@@ -0,0 +1,19 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);
+5
View File
@@ -0,0 +1,5 @@
{
"name": "License Tracker Pro v.1.2",
"description": "A web application to track software license purchase dates, renewal dates, costs, and vendor information with a fiscal year timeline and price history graphs.",
"requestFramePermissions": []
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "license-tracker",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0",
"@google/genai": "^1.34.0"
},
"devDependencies": {
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.2.2",
"vite": "^5.1.6"
}
}
BIN
View File
Binary file not shown.
View File
View File
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"fallthroughCasesInSwitch": true
},
"include": ["./**/*.ts", "./**/*.tsx"]
}
+102
View File
@@ -0,0 +1,102 @@
export interface Renewal {
id: string;
renewalDate: string;
endDate?: string;
renewalPrice: number;
notes?: string;
}
export interface LicenseFile {
id: number;
fileName: string;
mimeType: string;
fileSize: number;
}
export interface NotificationGroup {
id: number;
groupName: string;
emails: string;
created_at: string;
}
export interface Contact {
id?: number;
vendorName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
}
export interface AuditLog {
id: number;
user_id: number;
username: string;
action_type: 'CREATE' | 'UPDATE' | 'DELETE' | 'LOGIN' | 'LOGOUT';
entity_type: string;
entity_id: string;
entity_name: string;
details: string;
created_at: string;
}
export interface License {
id:string;
image?: string;
licenseName: string;
companyName: string;
responsiblePerson: string;
purchaseDate: string;
endDate?: string;
purchasePrice: number;
vendorName: string;
vendorContact: {
name: string;
email: string;
phone: string;
};
notificationGroupId?: number | null;
comments: string;
renewals: Renewal[];
files?: LicenseFile[];
tags?: string[];
isActive: boolean;
}
export interface User {
id: number;
username: string;
email?: string;
role: 'admin' | 'editor';
auth_source: 'local' | 'azure' | 'ldap';
created_at: string;
}
export interface AppSettings {
fiscal_start_month: number;
alert_days: number;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
azure_enabled: boolean;
azure_tenant_id: string;
azure_client_id: string;
azure_client_secret: string;
ldap_enabled: boolean;
ldap_host: string;
ldap_port: string;
ldap_base_dn: string;
ldap_bind_dn: string;
ldap_bind_pass: string;
ldap_user_filter: string;
allow_local_login: boolean;
}
export interface AIInsight {
status: 'saving' | 'warning' | 'neutral';
advice: string;
suggested_alternatives: string[];
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
emptyOutDir: true,
assetsDir: 'assets',
},
server: {
proxy: {
'/api.php': {
target: 'http://localhost:8081',
changeOrigin: true,
secure: false
}
}
}
});