From e04535489283de5af620e4a489478c7c0fee0e15 Mon Sep 17 00:00:00 2001 From: Philip Date: Mon, 26 Jan 2026 18:55:43 -0800 Subject: [PATCH] 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. --- .dockerignore | 1 + .gitignore | 24 ++ App.tsx | 256 +++++++++++++++++++++ AuthProviderGate.tsx | 0 Dockerfile | 1 + Dockerfile.db | 1 + README.md | 25 +- actions/delete_license.php | 0 actions/save_license.php | 0 api.php | 379 +++++++++++++++++++++++++++++++ api/auth.php | 0 api/check-auth.php | 0 api/get_license.php | 0 api/licenses.php | 0 api/logout.php | 0 components/AdminSettings.tsx | 302 ++++++++++++++++++++++++ components/AuditLogs.tsx | 91 ++++++++ components/ConfigModal.tsx | 0 components/DashboardAlerts.tsx | 118 ++++++++++ components/ErrorBoundary.tsx | 83 +++++++ components/Header.tsx | 105 +++++++++ components/Icons.php | 0 components/Icons.tsx | 122 ++++++++++ components/LicenseForm.php | 0 components/LicenseForm.tsx | 317 ++++++++++++++++++++++++++ components/LicenseItem.php | 0 components/LicenseItem.tsx | 149 ++++++++++++ components/LicenseList.tsx | 38 ++++ components/LoginForm.tsx | 130 +++++++++++ components/PriceHistoryChart.php | 0 components/PriceHistoryChart.tsx | 42 ++++ components/ResetPassword.tsx | 74 ++++++ components/Timeline.php | 0 components/Timeline.tsx | 175 ++++++++++++++ components/UserManagement.tsx | 279 +++++++++++++++++++++++ components/api.php | 0 db.php | 0 docker-compose.yml | 44 ++++ functions.php | 0 graphService.ts | 99 ++++++++ index.css | 330 +++++++++++++++++++++++++++ index.html | 39 ++++ index.php | 0 index.tsx | 2 + license.php | 0 login.php | 0 logout.php | 0 main.tsx | 19 ++ metadata.json | 5 + package.json | 24 ++ schema.sql | Bin 0 -> 37 bytes style.css | 0 templates/login_form.php | 0 tsconfig.json | 20 ++ types.ts | 102 +++++++++ vite.config.ts | 22 ++ 56 files changed, 3410 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 App.tsx create mode 100644 AuthProviderGate.tsx create mode 100644 Dockerfile create mode 100644 Dockerfile.db create mode 100644 actions/delete_license.php create mode 100644 actions/save_license.php create mode 100644 api.php create mode 100644 api/auth.php create mode 100644 api/check-auth.php create mode 100644 api/get_license.php create mode 100644 api/licenses.php create mode 100644 api/logout.php create mode 100644 components/AdminSettings.tsx create mode 100644 components/AuditLogs.tsx create mode 100644 components/ConfigModal.tsx create mode 100644 components/DashboardAlerts.tsx create mode 100644 components/ErrorBoundary.tsx create mode 100644 components/Header.tsx create mode 100644 components/Icons.php create mode 100644 components/Icons.tsx create mode 100644 components/LicenseForm.php create mode 100644 components/LicenseForm.tsx create mode 100644 components/LicenseItem.php create mode 100644 components/LicenseItem.tsx create mode 100644 components/LicenseList.tsx create mode 100644 components/LoginForm.tsx create mode 100644 components/PriceHistoryChart.php create mode 100644 components/PriceHistoryChart.tsx create mode 100644 components/ResetPassword.tsx create mode 100644 components/Timeline.php create mode 100644 components/Timeline.tsx create mode 100644 components/UserManagement.tsx create mode 100644 components/api.php create mode 100644 db.php create mode 100644 docker-compose.yml create mode 100644 functions.php create mode 100644 graphService.ts create mode 100644 index.css create mode 100644 index.html create mode 100644 index.php create mode 100644 index.tsx create mode 100644 license.php create mode 100644 login.php create mode 100644 logout.php create mode 100644 main.tsx create mode 100644 metadata.json create mode 100644 package.json create mode 100644 schema.sql create mode 100644 style.css create mode 100644 templates/login_form.php create mode 100644 tsconfig.json create mode 100644 types.ts create mode 100644 vite.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2fa5e2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +��^�j�W�v+-���u�� h� \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -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? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000..836b967 --- /dev/null +++ b/App.tsx @@ -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([]); + const [currentUser, setCurrentUser] = useState(null); + const [appSettings, setAppSettings] = useState(DEFAULT_SETTINGS); + const [currentView, setCurrentView] = useState('dashboard'); + const [isModalOpen, setIsModalOpen] = useState(false); + const [showLoginModal, setShowLoginModal] = useState(false); + const [editingLicense, setEditingLicense] = useState(null); + const [timelineDate, setTimelineDate] = useState(new Date()); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedTags, setSelectedTags] = useState([]); + 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) => { + 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(); + 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 ( +
+
+
{ 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' ? : currentView === 'logs' ? : currentView === 'settings' ? : ( +
+ + setTimelineDate(d => { const n = new Date(d); n.setFullYear(d.getFullYear() + (dir === 'next' ? 1 : -1)); return n; })} fiscalStartMonth={appSettings.fiscal_start_month} /> + +
+

{showArchived ? 'Archive' : 'Active Inventory'}

+
+ + {currentUser && ( + + )} +
+
+ +
+
+
+ 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" + /> +
+ +
+ +
+
+ + {selectedTags.length > 0 && ( + + )} +
+
+ {allTags.length === 0 ? ( + No tags available in current inventory. + ) : ( + allTags.map(tag => { + const isActive = selectedTags.includes(tag); + return ( + + ); + }) + )} +
+
+
+ + {isLoading ? ( +
LOADING REPOSITORY...
+ ) : ( + { setEditingLicense(l); setIsModalOpen(true); }} onDelete={async (id) => { if(confirm('Delete?')) { await deleteLicense(id); initApp(); } }} isReadOnly={!currentUser} /> + )} +
+ )} +
+ setIsModalOpen(false)} onSubmit={handleFormSubmit} initialData={editingLicense} /> + {showLoginModal && { setCurrentUser(u); setShowLoginModal(false); initApp(); }} onCancel={() => setShowLoginModal(false)} />} +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/AuthProviderGate.tsx b/AuthProviderGate.tsx new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b2b319 --- /dev/null +++ b/Dockerfile @@ -0,0 +1 @@ +� \ No newline at end of file diff --git a/Dockerfile.db b/Dockerfile.db new file mode 100644 index 0000000..1b2b319 --- /dev/null +++ b/Dockerfile.db @@ -0,0 +1 @@ +� \ No newline at end of file diff --git a/README.md b/README.md index 2241000..ef3194a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,20 @@
- GHBanner - -

Built with AI Studio

- -

The fastest path from prompt to production with Gemini.

- - Start building -
+ +# 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` diff --git a/actions/delete_license.php b/actions/delete_license.php new file mode 100644 index 0000000..e69de29 diff --git a/actions/save_license.php b/actions/save_license.php new file mode 100644 index 0000000..e69de29 diff --git a/api.php b/api.php new file mode 100644 index 0000000..e3d910b --- /dev/null +++ b/api.php @@ -0,0 +1,379 @@ + 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 = "

Expiration Alert

License: {$l['licenseName']}
Expiry: {$l['endDate']}
Stage: {$labels[$targetLvl]}

"; + 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", "

SMTP Test Successful

"); + 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); +} diff --git a/api/auth.php b/api/auth.php new file mode 100644 index 0000000..e69de29 diff --git a/api/check-auth.php b/api/check-auth.php new file mode 100644 index 0000000..e69de29 diff --git a/api/get_license.php b/api/get_license.php new file mode 100644 index 0000000..e69de29 diff --git a/api/licenses.php b/api/licenses.php new file mode 100644 index 0000000..e69de29 diff --git a/api/logout.php b/api/logout.php new file mode 100644 index 0000000..e69de29 diff --git a/components/AdminSettings.tsx b/components/AdminSettings.tsx new file mode 100644 index 0000000..9e71947 --- /dev/null +++ b/components/AdminSettings.tsx @@ -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 = ({ 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(DEFAULT_SETTINGS); + const [users, setUsers] = useState([]); + const [groups, setGroups] = useState([]); + const [contacts, setContacts] = useState([]); + + const [editingUserId, setEditingUserId] = useState(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 ( +
+
+

System Admin

+ {[ + {id:'general', label:'Application', icon:}, + {id:'auth', label:'Identity & SSO', icon:}, + {id:'users', label:'Local Users', icon:}, + {id:'groups', label:'Alert Groups', icon:}, + {id:'vendors', label:'Vendors', icon:} + ].map(tab => ( + + ))} +
+ +
+ {message && ( +
+ {message.text} + +
+ )} + + {activeTab === 'general' && ( +
+
+

Core Defaults

+
+
+
setSettings({...settings, alert_days: parseInt(e.target.value)})} className="input-style" />
+
+
+
+

SMTP Server

+
+ setSettings({...settings, smtp_host: e.target.value})} className="input-style" /> + setSettings({...settings, smtp_port: e.target.value})} className="input-style" /> + setSettings({...settings, smtp_user: e.target.value})} className="input-style" /> + setSettings({...settings, smtp_pass: e.target.value})} className="input-style" /> + setSettings({...settings, smtp_from: e.target.value})} className="input-style md:col-span-2" /> +
+
+
+
+ )} + + {activeTab === 'auth' && ( +
+
+

Identity & SSO

+
+
+
+
+

Microsoft Entra (Azure)

+
+ setSettings({...settings, azure_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" /> +
+ {settings.azure_enabled && ( +
+ setSettings({...settings, azure_tenant_id: e.target.value})} className="input-style" /> + setSettings({...settings, azure_client_id: e.target.value})} className="input-style" /> + setSettings({...settings, azure_client_secret: e.target.value})} className="input-style" /> +
+ )} +
+ +
+
+

LDAP / Active Directory

+ setSettings({...settings, ldap_enabled: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" /> +
+ {settings.ldap_enabled && ( +
+ setSettings({...settings, ldap_host: e.target.value})} className="input-style" /> + setSettings({...settings, ldap_port: e.target.value})} className="input-style" /> + setSettings({...settings, ldap_base_dn: e.target.value})} className="input-style md:col-span-2" /> + setSettings({...settings, ldap_bind_dn: e.target.value})} className="input-style" /> + setSettings({...settings, ldap_bind_pass: e.target.value})} className="input-style" /> + setSettings({...settings, ldap_user_filter: e.target.value})} className="input-style md:col-span-2" /> +
+ )} +
+
+

Allow Local DB Login

+ setSettings({...settings, allow_local_login: e.target.checked})} className="w-5 h-5 rounded bg-slate-800 border-slate-600 text-cyan-500" /> +
+
+
+
+
+ )} + + {activeTab === 'users' && ( +
+
+ setNewUser({...newUser, username: e.target.value})} className="input-style" /> + setNewUser({...newUser, email: e.target.value})} className="input-style" /> + setNewUser({...newUser, password: e.target.value})} className="input-style" /> +
+ + +
+
+
+ + + {users.map(u => ( + + + + + ))} + +
UserRoleActions
{u.username}
{u.email}
{u.role} + + {u.id !== 1 && } +
+
+
+ )} + + {activeTab === 'groups' && ( +
+
+ setNewGroup({...newGroup, groupName: e.target.value})} className="input-style flex-1" /> + setNewGroup({...newGroup, emails: e.target.value})} className="input-style flex-[2]" /> + +
+
+ + + {groups.map(g => ( + ))} +
NameRecipientsActions
{g.groupName}{g.emails}
+
+
+ )} + + {activeTab === 'vendors' && ( +
+
+ setNewVendor({...newVendor, vendorName: e.target.value})} className="input-style" /> + setNewVendor({...newVendor, contactName: e.target.value})} className="input-style" /> + setNewVendor({...newVendor, contactEmail: e.target.value})} className="input-style" /> +
+ setNewVendor({...newVendor, contactPhone: e.target.value})} className="input-style flex-1" /> + +
+
+
+ + + {contacts.map(c => ( + + + + + + ))} +
VendorContactEmailActions
{c.vendorName}{c.contactName || '---'}{c.contactEmail || '---'}
+
+
+ )} +
+ + +
+ ); +}; + +export default AdminSettings; \ No newline at end of file diff --git a/components/AuditLogs.tsx b/components/AuditLogs.tsx new file mode 100644 index 0000000..4ff3aa0 --- /dev/null +++ b/components/AuditLogs.tsx @@ -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([]); + 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
QUERYING SYSTEM JOURNALS...
; + if (error) return ( +
+

LOG_FETCH_FAILURE: {error}

+ +
+ ); + + return ( +
+
+
+

System Audit Journal

+

Chronological Modification Tracking

+
+ +
+
+ + + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + {logs.length === 0 && ( + + + + )} + +
TimestampIdentityOperationObjectDetails
{new Date(log.created_at).toLocaleString()}{log.username} + + {log.action_type} + + + {log.entity_type} + {log.entity_name} + {log.details}
No system events logged in current period.
+
+
+ ); +}; + +export default AuditLogs; \ No newline at end of file diff --git a/components/ConfigModal.tsx b/components/ConfigModal.tsx new file mode 100644 index 0000000..e69de29 diff --git a/components/DashboardAlerts.tsx b/components/DashboardAlerts.tsx new file mode 100644 index 0000000..53d5590 --- /dev/null +++ b/components/DashboardAlerts.tsx @@ -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 = ({ 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 ( +
+
+
+

+ + Upcoming Expirations & Renewals +

+ + Next {alertDays} Days + +
+ +
+ {alerts.map((alert, idx) => ( +
+
+
+ {alert.type === 'Expiration' ? : } +
+ + {alert.daysRemaining === 0 ? 'Today' : `${alert.daysRemaining} days`} + +
+
+

{alert.licenseName}

+

{alert.type}

+

{alert.date.toLocaleDateString()}

+
+
+ ))} +
+
+
+ ); +}; + +export default DashboardAlerts; diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx new file mode 100644 index 0000000..bde0fc3 --- /dev/null +++ b/components/ErrorBoundary.tsx @@ -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 { + // 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 ( +
+
+
+
+ + + +
+

Application Failure

+
+

The interface encountered an unexpected error during rendering.

+
+ {error?.stack || error?.toString()} +
+
+ + +
+
+
+ ); + } + + return children || null; + } +} diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 0000000..2046a40 --- /dev/null +++ b/components/Header.tsx @@ -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) => void; + onExport: () => void; + currentView: string; + onChangeView: (view: string) => void; +} + +const Header: React.FC = ({ user, onLogout, onLoginClick, onImport, onExport, currentView, onChangeView }) => { + const fileInputRef = useRef(null); + + return ( +
+
onChangeView('dashboard')}> +

+ License Tracker +

+

+ {user ? `Welcome, ${user.username} (${user.role})` : 'Read-Only Mode'} +

+
+ +
+ + {/* Nav Links */} +
+ + + {user && user.role === 'admin' && ( + <> + + + + )} +
+ + {/* Tools (Backup/Restore) - Restricted to Admins */} + {user && user.role === 'admin' && ( +
+ +
+ + +
+ )} + + {/* Auth Button */} + {user ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/components/Icons.php b/components/Icons.php new file mode 100644 index 0000000..e69de29 diff --git a/components/Icons.tsx b/components/Icons.tsx new file mode 100644 index 0000000..b806117 --- /dev/null +++ b/components/Icons.tsx @@ -0,0 +1,122 @@ + +import React from 'react'; + +interface IconProps { + className?: string; +} + +export const PlusIcon: React.FC = ({ className = 'w-6 h-6' }) => ( + + + +); + +export const EditIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const TrashIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const CalendarIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const PriceIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const UserIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const ChevronLeftIcon: React.FC = ({ className = 'w-6 h-6' }) => ( + + + +); + +export const ChevronRightIcon: React.FC = ({ className = 'w-6 h-6' }) => ( + + + +); + +export const ArrowUpIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const ArrowDownIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const MinusIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const BriefcaseIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const SaveIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const BellIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const ExclamationIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const SettingsIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + + +); + +export const ArchiveIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); + +export const EyeIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + + +); + +export const EyeOffIcon: React.FC = ({ className = 'w-5 h-5' }) => ( + + + +); diff --git a/components/LicenseForm.php b/components/LicenseForm.php new file mode 100644 index 0000000..e69de29 diff --git a/components/LicenseForm.tsx b/components/LicenseForm.tsx new file mode 100644 index 0000000..1ea3ec6 --- /dev/null +++ b/components/LicenseForm.tsx @@ -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 = { + 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 = ({ isOpen, onClose, onSubmit, initialData }) => { + const [license, setLicense] = useState>({ ...emptyLicense }); + const [tagsInput, setTagsInput] = useState(''); + const [newFiles, setNewFiles] = useState<{ name: string; type: string; data: string }[]>([]); + const [contacts, setContacts] = useState([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const logoInputRef = useRef(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) => { + 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) => { + if (e.target.files) { + Array.from(e.target.files).forEach((file: File) => { + const reader = new FileReader(); + reader.onload = (evt: ProgressEvent) => { + 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 ( +
+
+
+

{initialData ? 'Update License Details' : 'Register New License'}

+
+ + +
+
+ +
+
+
logoInputRef.current?.click()}> + +
+ Change Logo +
+ +
+
+
+
setLicense({...license, licenseName: e.target.value})} required className="input-style text-lg font-bold" placeholder="e.g. Photoshop Pro" />
+
setLicense({...license, companyName: e.target.value})} className="input-style" placeholder="e.g. Marketing Dept" />
+
+
+ + setTagsInput(e.target.value)} + className="input-style" + placeholder="IT, SaaS, Creative" + /> +
+
+
+ +
+
+ +
+ + +
+
+ +