diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a43aa0a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,89 @@
+node_modules
+node_modules/
+**/node_modules/
+package-lock.json
+package.lock.json
+.env
+
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# next.js build output
+.next
+
+# nuxt.js build output
+.nuxt
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# OS metadata
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b8f1c1f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,226 @@
+
` container. |
+| **`styles.css`** | The complete design token system. Contains all CSS variables for the Light and Dark themes, responsive layout grids, sidebar styling, button states, and the custom date picker styling. |
+| **`design.js`** | The UI rendering engine. It handles loading dynamic SVGs from the local `/thesvg` route and houses the complex logic for rendering, navigating, and interacting with the custom calendar date picker. |
+| **`main.js`** | The central nervous system of the SPA. It handles the `App` state, checks JWT validity, triggers the theme toggler, handles asynchronous API wrappers (`App.api`), and provides global utility functions like toast notifications. |
+
+#### π§© Application Modules
+| File | Description |
+| :--- | :--- |
+| **`setup.js`** | The first-run initialization wizard. If the database is empty, this module guides the user to create the master company profile and the first Admin account. |
+| **`login.js`** | Renders the beautiful login screen, authenticates credentials with the backend, stores the JWT securely, and routes to the correct dashboard based on the user's role. |
+| **`admin.js`** | The administrative portal. Manages the DOM injection for employee directories, leave policy configuration tables, and the master leave request approval system. |
+| **`employee.js`** | The employee portal. Renders personalized stat cards, leave allowance progress bars, and handles the logic for submitting new time-off requests. |
+| **`reports.js`** | The reporting UI controller. It manages the complex filtering forms, queries the API with URL parameters, and renders the data tables. |
+| **`pdf_reports.js`** | The specialized PDF export engine. It takes JSON data from `reports.js`, configures document layouts, creates custom headers, and utilizes `jspdf-autotable` to draw perfectly aligned tables before initiating a browser download. |
+
+
+
+---
+
+## π Complete Installation & Setup Guide
+
+Because CYPHER-HR is self-hosted, your data remains 100% yours. Follow these steps to deploy the system locally or on your own server.
+
+### Step 1: System Requirements
+Ensure you have the following installed on your machine:
+- **Node.js** (v16 or higher)
+- **PostgreSQL** (v12 or higher)
+- **Git**
+
+### Step 2: Database Preparation
+CYPHER-HR requires a dedicated PostgreSQL database and user.
+1. Open your terminal and access the Postgres CLI:
+
psql -U postgres
+2. Create the user and database:
+ ```sql
+ CREATE USER kencypher WITH PASSWORD 'secure_password';
+ CREATE DATABASE cypher_hr_db OWNER kencypher;
+ ```
+3. Initialize the schema. Exit `psql` and run:
+
psql -U kencypher -d cypher_hr_db -f schema.sql
+
+### Step 3: Environment Configuration
+Create a `.env` file in the root directory of the project. This keeps your secrets secure.
+```env
+# Server Configuration
+PORT=5200
+JWT_SECRET=generate_a_random_secure_string_here
+
+# Database Configuration
+DB_USER=kencypher
+DB_PASS=secure_password
+DB_HOST=localhost
+DB_PORT=5432
+DB_NAME=cypher_hr_db
+```
+
+### Step 4: Install Dependencies
+Install the required Node packages (`express`, `pg`, `bcryptjs`, `jsonwebtoken`, `cors`, `dotenv`):
+```bash
+npm install
+```
+
+### Step 5: Launch the System
+Start the backend server:
+```bash
+node server.js
+```
+The console will confirm: `[CYPHER-HR] Server active on port 5200` and `[CYPHER-HR] Database connected`.
+
+### Step 6: Initial Setup Wizard
+1. Open your web browser and navigate to **`http://localhost:5200`**.
+2. Because it is the first time running the system, CYPHER-HR will automatically present the **System Setup Wizard**.
+3. Follow the UI prompts to configure your company details and create your Master Administrator account.
+4. Log in and begin adding your employees!
+
+
+
+---
+
+## π€ Contributing to CYPHER-HR
+
+Open source thrives on community. If you are a developer, designer, or HR professional who wants to improve this system:
+1. **Fork** the repository.
+2. Create a new branch:
git checkout -b feature/amazing-new-feature
+3. Commit your changes:
git commit -m 'Add amazing new feature'
+4. Push to the branch:
git push origin feature/amazing-new-feature
+5. Open a **Pull Request**.
+
+All contributions, bug reports, and feature requests are highly welcome. Let's build the ultimate free HR tool together.
+
+
+
+## π License & Freedom
+
+This project is licensed under the **MIT License**.
+
+You are free to use it, modify it, distribute it, and run it for your business without ever paying a dime. As Linus Torvalds demonstrated with Linux, the greatest tools are built when we share knowledge and empower each other.
+
+
+
+
+
Built with passion and a commitment to transparency by KENCYPHER.
+
Give this repository a β if it helped your business!
+
diff --git a/admin.js b/admin.js
new file mode 100644
index 0000000..575b287
--- /dev/null
+++ b/admin.js
@@ -0,0 +1,363 @@
+/* βββ CYPHER-HR Admin Module βββ */
+const Admin = {
+ async showDashboard() {
+ App.setView(this.layout('dashboard', await this.dashboardContent()));
+ this.bindNav();
+ },
+
+ layout(active, content) {
+ const navItems = [
+ { id: 'dashboard', icon: 'dashboard', label: 'Dashboard' },
+ { id: 'employees', icon: 'users', label: 'Employees' },
+ { id: 'leaves', icon: 'calendar', label: 'Leave Requests' },
+ { id: 'policies', icon: 'settings', label: 'Policies' },
+ { id: 'reports', icon: 'report', label: 'Reports' },
+ ];
+ return `
+
`;
+ },
+
+ bindNav() {
+ document.querySelectorAll('.nav-item[data-view]').forEach(el => {
+ el.addEventListener('click', () => this.navigate(el.dataset.view));
+ });
+ },
+
+ async navigate(view) {
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
+ document.querySelector(`[data-view="${view}"]`)?.classList.add('active');
+ document.querySelector('.topbar h2').textContent =
+ { dashboard: 'Dashboard', employees: 'Employees', leaves: 'Leave Requests', policies: 'Policies', reports: 'Reports' }[view];
+ const area = document.getElementById('contentArea');
+ area.style.opacity = '0';
+ setTimeout(async () => {
+ if (view === 'dashboard') area.innerHTML = await this.dashboardContent();
+ else if (view === 'employees') area.innerHTML = await this.employeesContent();
+ else if (view === 'leaves') area.innerHTML = await this.leavesContent();
+ else if (view === 'policies') area.innerHTML = await this.policiesContent();
+ else if (view === 'reports') area.innerHTML = await Reports.adminView();
+ area.style.opacity = '1';
+ }, 200);
+ },
+
+ async dashboardContent() {
+ try {
+ const stats = await App.api('/api/dashboard/stats');
+ const leaves = await App.api('/api/leaves');
+ const pending = leaves.filter(l => l.status === 'pending').slice(0, 5);
+ return `
+
+
${icon('users', 28)}
${stats.totalEmployees} Total Employees
+
${icon('check', 28)}
${stats.activeEmployees} Active Employees
+
${icon('clock', 28)}
${stats.pendingLeaves} Pending Requests
+
${icon('calendar', 28)}
${stats.approvedToday} Approved Today
+
+
+
+
+ ${pending.length === 0 ? '
No pending requests
' :
+ `
Employee Leave Type Dates Status Actions
+ ${pending.map(l => `
+ ${App.avatar(l)}${l.first_name} ${l.last_name}
+ ${l.leave_type}
+ ${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}
+ ${App.statusBadge(l.status)}
+
+ ${icon('check',14)} Approve
+ ${icon('x',14)} Reject
+
+ `).join('')}
+
`}
+
+
`;
+ } catch (err) { return `
Error loading dashboard: ${err.message}
`; }
+ },
+
+ async employeesContent() {
+ try {
+ const employees = await App.api('/api/employees');
+ return `
+
+
+ ${employees.length === 0 ? '
No employees yet. Add your first employee!
' :
+ employees.map(emp => `
+
+
+
+
${icon('briefcase',14)} ${emp.department || 'β'}
+
${icon('calendar',14)} ${emp.position || 'β'}
+
+
+ ${icon('edit',14)} Edit
+ ${icon('lock',14)} Reset Pwd
+ ${icon('calendar',14)} Leave Allowances
+ ${emp.is_active ? icon('x',14)+' Deactivate' : icon('check',14)+' Activate'}
+ ${icon('trash',14)}
+
+
`).join('')}
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ filterEmployees(q) {
+ document.querySelectorAll('.employee-card').forEach(c => {
+ c.style.display = c.dataset.name.toLowerCase().includes(q.toLowerCase()) ? '' : 'none';
+ });
+ },
+
+ showAddEmployee() {
+ showModal('Add Employee', `
+
`,
+ `
Add Employee `
+ );
+ },
+
+ async addEmployee(e) {
+ e.preventDefault();
+ try {
+ await App.api('/api/employees', {
+ method: 'POST',
+ body: JSON.stringify({
+ first_name: document.getElementById('empFirst').value,
+ last_name: document.getElementById('empLast').value,
+ email: document.getElementById('empEmail').value,
+ password: document.getElementById('empPassword').value,
+ department: document.getElementById('empDept').value,
+ position: document.getElementById('empPos').value,
+ phone: document.getElementById('empPhone').value
+ })
+ });
+ closeModal();
+ App.toast('Employee added successfully');
+ this.navigate('employees');
+ } catch (err) {
+ const el = document.getElementById('addEmpError');
+ el.textContent = err.message; el.style.display = 'block';
+ }
+ },
+
+ async deleteEmployee(id, name) {
+ if (!confirm(`Delete employee "${name}"? This cannot be undone.`)) return;
+ try {
+ await App.api(`/api/employees/${id}`, { method: 'DELETE' });
+ App.toast('Employee deleted');
+ this.navigate('employees');
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ async toggleEmployee(id) {
+ try {
+ await App.api(`/api/employees/${id}/toggle`, { method: 'PUT' });
+ App.toast('Employee status updated');
+ this.navigate('employees');
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ showEditEmployee(emp) {
+ showModal('Edit Employee', `
+
`,
+ `
Cancel
+
Save Changes `
+ );
+ },
+
+ async editEmployee(e, id) {
+ e.preventDefault();
+ try {
+ await App.api(`/api/employees/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({
+ first_name: document.getElementById('editFirst').value,
+ last_name: document.getElementById('editLast').value,
+ email: document.getElementById('editEmail').value,
+ department: document.getElementById('editDept').value,
+ position: document.getElementById('editPos').value,
+ phone: document.getElementById('editPhone').value
+ })
+ });
+ closeModal();
+ App.toast('Employee updated successfully');
+ this.navigate('employees');
+ } catch (err) {
+ const el = document.getElementById('editEmpError');
+ el.textContent = err.message; el.style.display = 'block';
+ }
+ },
+
+ showResetPassword(id, name) {
+ showModal('Reset Password', `
+
Set a new password for ${name}
+
`,
+ `
Cancel
+
Reset Password `
+ );
+ },
+
+ async resetPassword(e, id) {
+ e.preventDefault();
+ const pwd = document.getElementById('newPassword').value;
+ const confirm = document.getElementById('confirmPassword').value;
+ const errEl = document.getElementById('resetPwdError');
+ if (pwd !== confirm) {
+ errEl.textContent = 'Passwords do not match';
+ errEl.style.display = 'block';
+ return;
+ }
+ try {
+ await App.api(`/api/employees/${id}/reset-password`, {
+ method: 'PUT',
+ body: JSON.stringify({ new_password: pwd })
+ });
+ closeModal();
+ App.toast('Password reset successfully');
+ } catch (err) {
+ errEl.textContent = err.message; errEl.style.display = 'block';
+ }
+ },
+
+ async showBalances(userId, name) {
+ try {
+ const balances = await App.api(`/api/balances?user_id=${userId}`);
+ showModal(`Leave Allowances β ${name}`, `
+
+ ${balances.map(b => `
+
+
${b.leave_type}
+
+ ${icon('arrowDown',14)}
+ ${b.balance}
+ ${icon('arrowUp',14)}
+
+
/ ${b.monthly_limit} max
+
`).join('')}
+
+ `);
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ async adjustBalance(balId, delta, userId, name) {
+ try {
+ const balances = await App.api(`/api/balances?user_id=${userId}`);
+ const bal = balances.find(b => b.id === balId);
+ if (!bal) return;
+ const newBal = Math.max(0, bal.balance + delta);
+ await App.api(`/api/balances/${balId}`, { method: 'PUT', body: JSON.stringify({ balance: newBal }) });
+ closeModal();
+ this.showBalances(userId, name);
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ async leavesContent() {
+ return Leaves.adminView();
+ },
+
+ async policiesContent() {
+ try {
+ const policies = await App.api('/api/policies');
+ return `
+
+ ${policies.map(p => `
+
+
+
+ Monthly Limit
+
+
+
`).join('')}
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ async togglePolicy(id, enabled, limit) {
+ try {
+ await App.api(`/api/policies/${id}`, { method: 'PUT', body: JSON.stringify({ is_enabled: enabled, monthly_limit: limit }) });
+ App.toast('Policy updated');
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ async updatePolicyLimit(id, limit, enabled) {
+ try {
+ await App.api(`/api/policies/${id}`, { method: 'PUT', body: JSON.stringify({ monthly_limit: parseInt(limit), is_enabled: enabled }) });
+ App.toast('Limit updated');
+ } catch (err) { App.toast(err.message, 'error'); }
+ }
+};
diff --git a/db_connection.js b/db_connection.js
new file mode 100644
index 0000000..f7b6ba0
--- /dev/null
+++ b/db_connection.js
@@ -0,0 +1,154 @@
+require('dotenv').config();
+const { Pool, Client } = require('pg');
+
+const pool = new Pool({
+ host: process.env.DB_HOST || 'localhost',
+ port: parseInt(process.env.DB_PORT) || 5432,
+ database: process.env.DB_NAME || 'cypher-hr',
+ user: process.env.DB_USER || 'KENCYPHER',
+ password: process.env.DB_PASSWORD || 'CYPHER-HR',
+});
+
+pool.on('error', (err) => {
+ console.error('Unexpected error on idle client', err);
+});
+
+const query = (text, params) => pool.query(text, params);
+
+const SCHEMA_SQL = `
+CREATE TABLE IF NOT EXISTS company_profile (
+ id SERIAL PRIMARY KEY,
+ company_name VARCHAR(255) NOT NULL,
+ address TEXT,
+ phone VARCHAR(30),
+ email VARCHAR(255),
+ website VARCHAR(255),
+ industry VARCHAR(100),
+ established_date DATE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ first_name VARCHAR(100) NOT NULL,
+ last_name VARCHAR(100) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'employee',
+ department VARCHAR(100),
+ position VARCHAR(100),
+ phone VARCHAR(30),
+ avatar_color VARCHAR(10) DEFAULT '#6366f1',
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS leave_policies (
+ id SERIAL PRIMARY KEY,
+ leave_type VARCHAR(60) NOT NULL UNIQUE,
+ monthly_limit INTEGER NOT NULL DEFAULT 1,
+ is_enabled BOOLEAN DEFAULT true,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS leave_balances (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ leave_policy_id INTEGER NOT NULL REFERENCES leave_policies(id) ON DELETE CASCADE,
+ balance INTEGER NOT NULL DEFAULT 0,
+ month INTEGER NOT NULL,
+ year INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(user_id, leave_policy_id, month, year)
+);
+
+CREATE TABLE IF NOT EXISTS leave_requests (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ leave_policy_id INTEGER NOT NULL REFERENCES leave_policies(id) ON DELETE CASCADE,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ reason TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ admin_remarks TEXT,
+ approved_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_leave_balances_user ON leave_balances(user_id);
+CREATE INDEX IF NOT EXISTS idx_leave_balances_period ON leave_balances(month, year);
+CREATE INDEX IF NOT EXISTS idx_leave_requests_user ON leave_requests(user_id);
+CREATE INDEX IF NOT EXISTS idx_leave_requests_status ON leave_requests(status);
+CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+`;
+
+async function initDatabase() {
+ const adminClient = new Client({
+ host: process.env.DB_HOST || 'localhost',
+ port: parseInt(process.env.DB_PORT) || 5432,
+ user: process.env.DB_USER || 'KENCYPHER',
+ password: process.env.DB_PASSWORD || 'CYPHER-HR',
+ database: 'postgres',
+ });
+
+ try {
+ await adminClient.connect();
+ const res = await adminClient.query(
+ "SELECT 1 FROM pg_database WHERE datname = $1",
+ [process.env.DB_NAME || 'cypher-hr']
+ );
+ if (res.rowCount === 0) {
+ await adminClient.query(`CREATE DATABASE "${process.env.DB_NAME || 'cypher-hr'}"`);
+ console.log(`Database "${process.env.DB_NAME}" created successfully`);
+ }
+ } catch (err) {
+ if (err.code !== '42P04') {
+ console.error('Error creating database:', err.message);
+ }
+ } finally {
+ await adminClient.end();
+ }
+
+ try {
+ await pool.query(SCHEMA_SQL);
+ console.log('Schema initialized successfully');
+ } catch (err) {
+ console.error('Error initializing schema:', err.message);
+ throw err;
+ }
+}
+
+async function resetMonthlyBalances() {
+ const now = new Date();
+ const currentMonth = now.getMonth() + 1;
+ const currentYear = now.getFullYear();
+
+ try {
+ const policies = await query('SELECT id, monthly_limit FROM leave_policies WHERE is_enabled = true');
+ const users_res = await query("SELECT id FROM users WHERE role = 'employee' AND is_active = true");
+
+ for (const user of users_res.rows) {
+ for (const policy of policies.rows) {
+ await query(
+ `INSERT INTO leave_balances (user_id, leave_policy_id, balance, month, year)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (user_id, leave_policy_id, month, year)
+ DO NOTHING`,
+ [user.id, policy.id, policy.monthly_limit, currentMonth, currentYear]
+ );
+ }
+ }
+ console.log(`Monthly balances initialized for ${currentMonth}/${currentYear}`);
+ } catch (err) {
+ console.error('Error resetting monthly balances:', err.message);
+ }
+}
+
+module.exports = { pool, query, initDatabase, resetMonthlyBalances, SCHEMA_SQL };
diff --git a/design.js b/design.js
new file mode 100644
index 0000000..514e219
--- /dev/null
+++ b/design.js
@@ -0,0 +1,232 @@
+/* βββ CYPHER-HR Design Module βββ */
+/* Icon management via thesvg CDN + SVG fallbacks */
+
+const THESVG_CDN = 'https://cdn.jsdelivr.net/npm/@thesvg/icons/icons';
+const iconCache = {};
+
+const SVG_ICONS = {
+ dashboard: `
`,
+ users: `
`,
+ calendar: `
`,
+ report: `
`,
+ settings: `
`,
+ logout: `
`,
+ plus: `
`,
+ check: `
`,
+ x: `
`,
+ clock: `
`,
+ briefcase: `
`,
+ building: `
`,
+ shield: `
`,
+ edit: `
`,
+ trash: `
`,
+ search: `
`,
+ chevronRight: `
`,
+ home: `
`,
+ download: `
`,
+ arrowUp: `
`,
+ arrowDown: `
`,
+ userPlus: `
`,
+ fileText: `
`,
+ checkCircle: `
`,
+ mail: `
`,
+ lock: `
`,
+ moon: `
`,
+ sun: `
`,
+};
+
+function icon(name, size = 20) {
+ const defaultSvg = SVG_ICONS[name] || SVG_ICONS.dashboard;
+
+ if (iconCache[name] && iconCache[name] !== 'loading') {
+ return `
${iconCache[name]} `;
+ }
+
+ if (!iconCache[name]) {
+ iconCache[name] = 'loading';
+ fetch(`/thesvg/${name}.js`)
+ .then(res => {
+ if (!res.ok) throw new Error('Not found');
+ return res.text();
+ })
+ .then(text => {
+ const match = text.match(/export const svg = \`(.*?)\`;/s) || text.match(/export const variants = \{[\s\S]*?"mono": \`(.*?)\`/s) || text.match(/export const variants = \{[\s\S]*?"default": \`(.*?)\`/s);
+ const finalSvg = (match && match[1]) ? match[1] : defaultSvg;
+ iconCache[name] = finalSvg;
+ document.querySelectorAll(`.thesvg-icon[data-icon="${name}"]`).forEach(el => {
+ el.innerHTML = finalSvg;
+ el.classList.remove('thesvg-icon');
+ });
+ })
+ .catch(err => {
+ iconCache[name] = defaultSvg;
+ });
+ }
+
+ return `
${defaultSvg} `;
+}
+
+/* βββ Design Utilities βββ */
+const Design = {
+ colors: ['#1570ef','#7a5af8','#ee46bc','#f04438','#12b76a','#f79009','#0ba5ec','#66c61c','#ef6820'],
+
+ randomColor() {
+ return this.colors[Math.floor(Math.random() * this.colors.length)];
+ },
+
+ initRipple() {
+ document.addEventListener('click', e => {
+ const btn = e.target.closest('.btn');
+ if (!btn) return;
+ const ripple = document.createElement('span');
+ const rect = btn.getBoundingClientRect();
+ ripple.style.cssText = `position:absolute;border-radius:50%;background:rgba(255,255,255,0.3);width:0;height:0;left:${e.clientX-rect.left}px;top:${e.clientY-rect.top}px;transform:translate(-50%,-50%);pointer-events:none;animation:ripple 0.4s ease-out forwards`;
+ btn.style.position = 'relative';
+ btn.style.overflow = 'hidden';
+ btn.appendChild(ripple);
+ setTimeout(() => ripple.remove(), 400);
+ });
+
+ if (!document.getElementById('ripple-style')) {
+ const s = document.createElement('style');
+ s.id = 'ripple-style';
+ s.textContent = '@keyframes ripple{to{width:200px;height:200px;opacity:0}}';
+ document.head.appendChild(s);
+ }
+ }
+};
+
+Design.initRipple();
+
+/* βββ Custom Date Picker (DD:MM:YYYY format) βββ */
+function dateInput(id, required = false, placeholder = 'DD:MM:YYYY') {
+ const req = required ? 'required' : '';
+ return `
+
`;
+}
+
+function formatDateInput(el) {
+ let v = el.value.replace(/[^\d]/g, '');
+ if (v.length > 2) v = v.slice(0,2) + ':' + v.slice(2);
+ if (v.length > 5) v = v.slice(0,5) + ':' + v.slice(5,9);
+ el.value = v;
+}
+
+function parseDateInput(id) {
+ const val = document.getElementById(id)?.value;
+ if (!val) return '';
+ const parts = val.split(':');
+ if (parts.length !== 3) return val;
+ return `${parts[2]}-${parts[1]}-${parts[0]}`;
+}
+
+let activeDatepicker = null;
+
+function openDatepicker(id) {
+ if (activeDatepicker && activeDatepicker !== id) closeDatepicker();
+ activeDatepicker = id;
+ const popup = document.getElementById(`${id}_popup`);
+ if (!popup) return;
+
+ const val = document.getElementById(id).value;
+ let d = new Date();
+ if (val && val.length === 10) {
+ const parts = val.split(':');
+ d = new Date(parts[2], parseInt(parts[1]) - 1, parts[0]);
+ }
+
+ renderCalendar(id, d.getMonth(), d.getFullYear());
+ popup.classList.add('show');
+}
+
+function closeDatepicker() {
+ if (!activeDatepicker) return;
+ const popup = document.getElementById(`${activeDatepicker}_popup`);
+ if (popup) popup.classList.remove('show');
+ activeDatepicker = null;
+}
+
+document.addEventListener('click', (e) => {
+ if (activeDatepicker) {
+ const wrapper = document.getElementById(`${activeDatepicker}_wrapper`);
+ if (wrapper && !wrapper.contains(e.target)) {
+ closeDatepicker();
+ }
+ }
+});
+
+function renderCalendar(id, month, year) {
+ const popup = document.getElementById(`${id}_popup`);
+ if (!popup) return;
+
+ const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+ const firstDay = new Date(year, month, 1).getDay();
+
+ let html = `
+
+
+
+
+ `;
+
+ for (let i = 0; i < firstDay; i++) {
+ html += `
`;
+ }
+
+ const today = new Date();
+ const isCurrentMonth = today.getMonth() === month && today.getFullYear() === year;
+
+ const currentVal = document.getElementById(id).value;
+ let selD = -1, selM = -1, selY = -1;
+ if (currentVal && currentVal.length === 10) {
+ const parts = currentVal.split(':');
+ selD = parseInt(parts[0]); selM = parseInt(parts[1]) - 1; selY = parseInt(parts[2]);
+ }
+
+ for (let i = 1; i <= daysInMonth; i++) {
+ let classes = 'datepicker-day';
+ if (isCurrentMonth && today.getDate() === i) classes += ' today';
+ if (selY === year && selM === month && selD === i) classes += ' selected';
+ html += `
${i}
`;
+ }
+
+ html += `
`;
+ popup.innerHTML = html;
+
+ // Fix nav buttons rotation
+ const navs = popup.querySelectorAll('.datepicker-nav');
+ if (navs[0]) navs[0].querySelector('svg').style.transform = 'rotate(180deg)';
+}
+
+function changeMonth(id, month, year) {
+ if (month < 0) { month = 11; year--; }
+ if (month > 11) { month = 0; year++; }
+ renderCalendar(id, month, year);
+}
+
+function selectDate(id, day, month, year) {
+ const dd = String(day).padStart(2, '0');
+ const mm = String(month + 1).padStart(2, '0');
+ const yyyy = year;
+
+ const input = document.getElementById(id);
+ input.value = `${dd}:${mm}:${yyyy}`;
+
+ // Trigger onchange manually if anything listens to it
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+
+ closeDatepicker();
+}
diff --git a/employee.js b/employee.js
new file mode 100644
index 0000000..b344a7d
--- /dev/null
+++ b/employee.js
@@ -0,0 +1,208 @@
+/* βββ CYPHER-HR Employee Module βββ */
+const Employee = {
+ async showDashboard() {
+ App.setView(this.layout('dashboard', await this.dashboardContent()));
+ this.bindNav();
+ },
+
+ layout(active, content) {
+ const navItems = [
+ { id: 'dashboard', icon: 'home', label: 'Dashboard' },
+ { id: 'apply', icon: 'plus', label: 'Apply Leave' },
+ { id: 'history', icon: 'clock', label: 'Leave History' },
+ { id: 'reports', icon: 'report', label: 'My Reports' },
+ ];
+ return `
+
`;
+ },
+
+ bindNav() {
+ document.querySelectorAll('.nav-item[data-view]').forEach(el => {
+ el.addEventListener('click', () => this.navigate(el.dataset.view));
+ });
+ },
+
+ async navigate(view) {
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
+ document.querySelector(`[data-view="${view}"]`)?.classList.add('active');
+ document.querySelector('.topbar h2').textContent =
+ { dashboard: 'Dashboard', apply: 'Apply Leave', history: 'Leave History', reports: 'My Reports' }[view];
+ const area = document.getElementById('contentArea');
+ area.style.opacity = '0';
+ setTimeout(async () => {
+ if (view === 'dashboard') area.innerHTML = await this.dashboardContent();
+ else if (view === 'apply') area.innerHTML = await this.applyContent();
+ else if (view === 'history') area.innerHTML = await this.historyContent();
+ else if (view === 'reports') area.innerHTML = await Reports.employeeView();
+ area.style.opacity = '1';
+ }, 200);
+ },
+
+ async dashboardContent() {
+ try {
+ const [balances, leaves] = await Promise.all([
+ App.api('/api/balances'),
+ App.api('/api/leaves')
+ ]);
+ const recent = leaves.slice(0, 5);
+ const pending = leaves.filter(l => l.status === 'pending').length;
+ const approved = leaves.filter(l => l.status === 'approved').length;
+
+ return `
+
+
+ ${App.avatar(App.user)}
+
+
${App.user.first_name} ${App.user.last_name}
+
${App.user.position || ''} ${App.user.department ? 'β’ ' + App.user.department : ''}
+
${App.user.email}
+
+
+
+
${pending} Pending
+
${approved} Approved
+
${leaves.length} Total
+
+
+
+
Leave Allowances
+
+ ${balances.filter(b => b.is_enabled).map(b => {
+ const pct = b.monthly_limit > 0 ? Math.round((b.balance / b.monthly_limit) * 100) : 0;
+ const color = pct > 50 ? '#10b981' : pct > 20 ? '#f59e0b' : '#ef4444';
+ return `
+
`;
+ }).join('')}
+
+
+
Recent Requests
+
+
+ ${recent.length === 0 ? '
No leave requests yet
' :
+ `
Type Dates Reason Status
+ ${recent.map(l => `
+ ${l.leave_type}
+ ${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}
+ ${l.reason || 'β'}
+ ${App.statusBadge(l.status)}
+ `).join('')}
+
`}
+
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ async applyContent() {
+ try {
+ const [policies, balances] = await Promise.all([
+ App.api('/api/policies'),
+ App.api('/api/balances')
+ ]);
+ const enabled = policies.filter(p => p.is_enabled);
+ return `
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ async submitLeave(e) {
+ e.preventDefault();
+ const err = document.getElementById('applyError');
+ err.style.display = 'none';
+ try {
+ await App.api('/api/leaves', {
+ method: 'POST',
+ body: JSON.stringify({
+ leave_policy_id: parseInt(document.getElementById('leaveType').value),
+ start_date: parseDateInput('leaveStart'),
+ end_date: parseDateInput('leaveEnd'),
+ reason: document.getElementById('leaveReason').value
+ })
+ });
+ App.toast('Leave request submitted!');
+ this.navigate('history');
+ } catch (error) { err.textContent = error.message; err.style.display = 'block'; }
+ },
+
+ async historyContent() {
+ try {
+ const leaves = await App.api('/api/leaves');
+ return `
+
+
+
+ ${leaves.length === 0 ? '
No leave history
' :
+ `
Type Start End Reason Status Remarks
+ ${leaves.map(l => `
+ ${l.leave_type}
+ ${App.formatDate(l.start_date)}
+ ${App.formatDate(l.end_date)}
+ ${l.reason || 'β'}
+ ${App.statusBadge(l.status)}
+ ${l.admin_remarks || 'β'}
+ `).join('')}
+
`}
+
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ }
+};
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..230de27
--- /dev/null
+++ b/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+
CYPHER-HR β Human Resource Management
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/leaves.js b/leaves.js
new file mode 100644
index 0000000..6799cc0
--- /dev/null
+++ b/leaves.js
@@ -0,0 +1,94 @@
+/* βββ CYPHER-HR Leaves Module βββ */
+const Leaves = {
+ async adminView() {
+ try {
+ const leaves = await App.api('/api/leaves');
+ const pending = leaves.filter(l => l.status === 'pending');
+ const processed = leaves.filter(l => l.status !== 'pending');
+ return `
+
+ Pending (${pending.length})
+ Processed (${processed.length})
+
+
+ ${pending.length === 0 ? '
No pending requests
' :
+ `
Employee Type Start End Reason Actions
+ ${pending.map(l => `
+ ${App.avatar(l)}${l.first_name} ${l.last_name}
+ ${l.leave_type}
+ ${App.formatDate(l.start_date)}
+ ${App.formatDate(l.end_date)}
+ ${l.reason || 'β'}
+
+ ${icon('check',14)} Approve
+ ${icon('x',14)} Reject
+
+ `).join('')}
+
`}
+
+
+ ${processed.length === 0 ? '
No processed requests
' :
+ `
Employee Type Dates Status Remarks
+ ${processed.map(l => `
+ ${App.avatar(l)}${l.first_name} ${l.last_name}
+ ${l.leave_type}
+ ${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}
+ ${App.statusBadge(l.status)}
+ ${l.admin_remarks || 'β'}
+ `).join('')}
+
`}
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ switchTab(tab, btn) {
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+ document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
+ btn.classList.add('active');
+ document.getElementById(tab === 'pending' ? 'tabPending' : 'tabProcessed').classList.add('active');
+ },
+
+ async approve(id) {
+ showModal('Approve Leave', `
+
+ Remarks (optional)
+ ${icon('check',18)} Confirm Approval
+
+ `);
+ },
+
+ async processApprove(e, id) {
+ e.preventDefault();
+ try {
+ await App.api(`/api/leaves/${id}/approve`, {
+ method: 'PUT',
+ body: JSON.stringify({ admin_remarks: document.getElementById('approveRemarks').value })
+ });
+ closeModal();
+ App.toast('Leave approved');
+ if (App.user.role === 'admin') Admin.navigate('leaves');
+ } catch (err) { App.toast(err.message, 'error'); }
+ },
+
+ async reject(id) {
+ showModal('Reject Leave', `
+
+ Reason for rejection
+ ${icon('x',18)} Confirm Rejection
+
+ `);
+ },
+
+ async processReject(e, id) {
+ e.preventDefault();
+ try {
+ await App.api(`/api/leaves/${id}/reject`, {
+ method: 'PUT',
+ body: JSON.stringify({ admin_remarks: document.getElementById('rejectRemarks').value })
+ });
+ closeModal();
+ App.toast('Leave rejected');
+ if (App.user.role === 'admin') Admin.navigate('leaves');
+ } catch (err) { App.toast(err.message, 'error'); }
+ }
+};
diff --git a/login.js b/login.js
new file mode 100644
index 0000000..81938ac
--- /dev/null
+++ b/login.js
@@ -0,0 +1,66 @@
+/* βββ CYPHER-HR Login Module βββ */
+const Login = {
+ show() {
+ App.setView(`
+
+
+
+
${icon('shield', 24)}
+
CYPHER-HR
+
Enterprise Human Resource Management
+
+
+
${icon('checkCircle', 16)}
Streamlined Leave Management
+
${icon('report', 16)}
Real-time Analytics & Reports
+
${icon('lock', 16)}
Role-based Access Control
+
${icon('clock', 16)}
Automated Balance Tracking
+
+
+
+
+
Welcome back
+
Sign in to your HR portal
+
+
+ Email Address
+
+
+
+ Password
+
+
+
+ Sign In ${icon('chevronRight', 16)}
+
+
+
+
+
+
+ `);
+ },
+
+ async handleLogin(e) {
+ e.preventDefault();
+ const btn = document.getElementById('loginBtn');
+ const err = document.getElementById('loginError');
+ btn.disabled = true;
+ btn.textContent = 'Signing in...';
+ err.style.display = 'none';
+ try {
+ const data = await App.api('/api/auth/login', {
+ method: 'POST',
+ body: JSON.stringify({
+ email: document.getElementById('loginEmail').value,
+ password: document.getElementById('loginPassword').value
+ })
+ });
+ App.login(data.token, data.user);
+ } catch (error) {
+ err.textContent = error.message;
+ err.style.display = 'block';
+ btn.disabled = false;
+ btn.innerHTML = `Sign In ${icon('chevronRight', 16)}`;
+ }
+ }
+};
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..8cac28d
--- /dev/null
+++ b/main.js
@@ -0,0 +1,119 @@
+/* βββ CYPHER-HR Main Application Module βββ */
+const API = '';
+const App = {
+ token: localStorage.getItem('cypher_hr_token'),
+ user: JSON.parse(localStorage.getItem('cypher_hr_user') || 'null'),
+ theme: localStorage.getItem('cypher_hr_theme') || 'light',
+
+ async init() {
+ this.applyTheme(this.theme);
+ if (this.token) {
+ try {
+ const me = await this.api('/api/auth/me');
+ this.user = me;
+ localStorage.setItem('cypher_hr_user', JSON.stringify(me));
+ if (me.role === 'admin') Admin.showDashboard();
+ else Employee.showDashboard();
+ } catch { this.logout(); }
+ } else {
+ const status = await this.api('/api/system/status');
+ if (!status.setupComplete) Setup.init(status);
+ else Login.show();
+ }
+ },
+
+ toggleTheme() {
+ this.theme = this.theme === 'light' ? 'dark' : 'light';
+ localStorage.setItem('cypher_hr_theme', this.theme);
+ this.applyTheme(this.theme);
+ // Update theme icon in topbar
+ const btn = document.getElementById('themeToggleBtn');
+ if (btn) btn.innerHTML = icon(this.theme === 'light' ? 'moon' : 'sun', 18);
+ },
+
+ applyTheme(theme) {
+ if (theme === 'dark') document.body.classList.add('dark-mode');
+ else document.body.classList.remove('dark-mode');
+ },
+
+ async api(url, options = {}) {
+ const headers = { 'Content-Type': 'application/json' };
+ if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
+ const res = await fetch(API + url, { ...options, headers });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || 'Request failed');
+ return data;
+ },
+
+ login(token, user) {
+ this.token = token;
+ this.user = user;
+ localStorage.setItem('cypher_hr_token', token);
+ localStorage.setItem('cypher_hr_user', JSON.stringify(user));
+ if (user.role === 'admin') Admin.showDashboard();
+ else Employee.showDashboard();
+ },
+
+ logout() {
+ this.token = null;
+ this.user = null;
+ localStorage.removeItem('cypher_hr_token');
+ localStorage.removeItem('cypher_hr_user');
+ Login.show();
+ },
+
+ setView(html) {
+ document.getElementById('app').innerHTML = html;
+ },
+
+ toast(msg, type = 'success') {
+ const t = document.createElement('div');
+ t.className = `toast toast-${type}`;
+ t.innerHTML = `${icon(type === 'success' ? 'checkCircle' : 'x', 16)}
${msg} `;
+ document.body.appendChild(t);
+ requestAnimationFrame(() => t.classList.add('show'));
+ setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 3000);
+ },
+
+ formatDate(d) {
+ if (!d) return 'β';
+ const dt = new Date(d);
+ const dd = String(dt.getDate()).padStart(2, '0');
+ const mm = String(dt.getMonth() + 1).padStart(2, '0');
+ const yyyy = dt.getFullYear();
+ return `${dd}:${mm}:${yyyy}`;
+ },
+
+ statusBadge(status) {
+ const map = { pending: 'warning', approved: 'success', rejected: 'danger' };
+ return `
${status.charAt(0).toUpperCase()+status.slice(1)} `;
+ },
+
+ avatar(user) {
+ const c = user.avatar_color || '#1570ef';
+ const initials = (user.first_name?.[0] || '') + (user.last_name?.[0] || '');
+ return `
${initials.toUpperCase()}
`;
+ }
+};
+
+/* βββ Modal Utility βββ */
+function showModal(title, content, footer = '') {
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+
+
+
${content}
+ ${footer ? `` : ''}
+
`;
+ document.body.appendChild(overlay);
+ overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
+ requestAnimationFrame(() => overlay.classList.add('show'));
+}
+
+function closeModal() {
+ const m = document.querySelector('.modal-overlay');
+ if (m) { m.classList.remove('show'); setTimeout(() => m.remove(), 200); }
+}
+
+document.addEventListener('DOMContentLoaded', () => App.init());
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..06e44a2
--- /dev/null
+++ b/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "cypher-hr",
+ "version": "1.0.0",
+ "description": "Production-ready HR Management System",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js",
+ "db:init": "node -e \"require('./db_connection').initDatabase()\""
+ },
+ "dependencies": {
+ "@thesvg/icons": "^2.1.5",
+ "bcryptjs": "^2.4.3",
+ "cors": "^2.8.5",
+ "dotenv": "^16.4.5",
+ "express": "^4.21.0",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.13.0"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.4"
+ }
+}
diff --git a/pdf_reports.js b/pdf_reports.js
new file mode 100644
index 0000000..3a6a070
--- /dev/null
+++ b/pdf_reports.js
@@ -0,0 +1,134 @@
+/* βββ CYPHER-HR PDF Reports Module βββ */
+const PdfReports = {
+ export() {
+ if (!Reports.lastData || Reports.lastData.length === 0) {
+ App.toast('Generate a report first to export', 'error');
+ return;
+ }
+
+ try {
+ const type = document.getElementById('reportType').value;
+ const { jsPDF } = window.jspdf;
+ const doc = new jsPDF();
+
+ // Header
+ doc.setFillColor(21, 112, 239); // Primary blue
+ doc.rect(0, 0, 210, 20, 'F');
+ doc.setTextColor(255, 255, 255);
+ doc.setFontSize(16);
+ doc.text('CYPHER-HR', 14, 13);
+
+ doc.setTextColor(50, 50, 50);
+ doc.setFontSize(14);
+ doc.text(type === 'detailed' ? 'Detailed Leave Report' : 'Summary Leave Report', 14, 30);
+
+ doc.setFontSize(10);
+ doc.setTextColor(100, 100, 100);
+ doc.text(`Generated on: ${App.formatDate(new Date())}`, 14, 36);
+
+ if (type === 'detailed') {
+ this.generateDetailed(doc, Reports.lastData);
+ } else {
+ this.generateSummary(doc, Reports.lastData);
+ }
+ } catch (err) {
+ console.error(err);
+ App.toast('Failed to generate PDF. Make sure libraries are loaded.', 'error');
+ }
+ },
+
+ generateDetailed(doc, data) {
+ const columns = [
+ { header: 'Employee', dataKey: 'employee' },
+ { header: 'Type', dataKey: 'type' },
+ { header: 'Dates', dataKey: 'dates' },
+ { header: 'Status', dataKey: 'status' },
+ { header: 'Department', dataKey: 'department' }
+ ];
+
+ const rows = data.map(r => ({
+ employee: `${r.first_name} ${r.last_name}`,
+ type: r.leave_type,
+ dates: `${App.formatDate(r.start_date)} to ${App.formatDate(r.end_date)}`,
+ status: r.status.charAt(0).toUpperCase() + r.status.slice(1),
+ department: r.department || 'β'
+ }));
+
+ doc.autoTable({
+ columns: columns,
+ body: rows,
+ startY: 42,
+ styles: { fontSize: 9, cellPadding: 4 },
+ headStyles: { fillColor: [21, 112, 239], textColor: 255 },
+ alternateRowStyles: { fillColor: [245, 247, 250] },
+ didDrawPage: function (data) {
+ doc.setFontSize(8);
+ doc.text(`Page ${doc.internal.getNumberOfPages()}`, data.settings.margin.left, doc.internal.pageSize.height - 10);
+ }
+ });
+
+ doc.save('detailed_report.pdf');
+ App.toast('PDF generated successfully');
+ },
+
+ generateSummary(doc, data) {
+ const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
+
+ // Group data for summary exactly like the UI
+ const grouped = {};
+ data.forEach(r => {
+ const name = `${r.first_name} ${r.last_name}`;
+ if (!grouped[name]) grouped[name] = {};
+ const m = months[parseInt(r.month) - 1];
+ if (!grouped[name][m]) grouped[name][m] = { approved: 0, rejected: 0, pending: 0 };
+ grouped[name][m][r.status] = (grouped[name][m][r.status] || 0) + parseInt(r.count);
+ });
+
+ let currentY = 42;
+
+ Object.entries(grouped).forEach(([name, monthData], index) => {
+ // Add employee name header
+ doc.setFontSize(11);
+ doc.setTextColor(30, 30, 30);
+ doc.text(name, 14, currentY);
+
+ const rows = Object.entries(monthData).map(([m, statuses]) => ({
+ month: m,
+ approved: statuses.approved || 0,
+ rejected: statuses.rejected || 0,
+ pending: statuses.pending || 0,
+ total: (statuses.approved || 0) + (statuses.rejected || 0) + (statuses.pending || 0)
+ }));
+
+ doc.autoTable({
+ columns: [
+ { header: 'Month', dataKey: 'month' },
+ { header: 'Approved', dataKey: 'approved' },
+ { header: 'Rejected', dataKey: 'rejected' },
+ { header: 'Pending', dataKey: 'pending' },
+ { header: 'Total', dataKey: 'total' }
+ ],
+ body: rows,
+ startY: currentY + 4,
+ margin: { bottom: 20 },
+ styles: { fontSize: 9, cellPadding: 3 },
+ headStyles: { fillColor: [240, 242, 245], textColor: [50, 50, 50] },
+ didDrawPage: function (data) {
+ doc.setFontSize(8);
+ doc.text(`Page ${doc.internal.getNumberOfPages()}`, data.settings.margin.left, doc.internal.pageSize.height - 10);
+ }
+ });
+
+ currentY = doc.lastAutoTable.finalY + 12;
+
+ // If close to bottom, add new page
+ if (currentY > 260 && index < Object.entries(grouped).length - 1) {
+ doc.addPage();
+ currentY = 20;
+ }
+ });
+
+ doc.save('summary_report.pdf');
+ App.toast('PDF generated successfully');
+ }
+};
diff --git a/reports.js b/reports.js
new file mode 100644
index 0000000..649d98e
--- /dev/null
+++ b/reports.js
@@ -0,0 +1,191 @@
+/* βββ CYPHER-HR Reports Module βββ */
+const Reports = {
+ async adminView() {
+ try {
+ const employees = await App.api('/api/employees');
+ return `
+
+
+
+
+
+
+
+
+ ${icon('search', 18)} Generate
+ ${icon('download', 18)} Export CSV
+ ${icon('fileText', 18)} Export PDF
+
+
+
+
+
`;
+ } catch (err) { return `
${err.message}
`; }
+ },
+
+ async employeeView() {
+ return `
+
+
+
+
+
+
+
+ ${icon('search', 18)} Generate
+ ${icon('download', 18)} Export CSV
+ ${icon('fileText', 18)} Export PDF
+
+
+
+
+
`;
+ },
+
+ lastData: [],
+
+ async generate() {
+ const type = document.getElementById('reportType').value;
+ const container = document.getElementById('reportResults');
+ container.innerHTML = '
Loading...
';
+ try {
+ if (type === 'detailed') {
+ const params = new URLSearchParams();
+ const empEl = document.getElementById('reportEmployee');
+ if (empEl && empEl.value) params.set('user_id', empEl.value);
+ const start = parseDateInput('reportStart');
+ const end = parseDateInput('reportEnd');
+ const status = document.getElementById('reportStatus')?.value;
+ if (start) params.set('start_date', start);
+ if (end) params.set('end_date', end);
+ if (status) params.set('status', status);
+ const data = await App.api(`/api/reports/detailed?${params}`);
+ this.lastData = data;
+ container.innerHTML = `
+
+
+
+ ${data.length === 0 ? '
No records found
' :
+ `
Employee Type Start End Reason Status Department
+ ${data.map(r => `
+ ${r.first_name} ${r.last_name}
+ ${r.leave_type}
+ ${App.formatDate(r.start_date)}
+ ${App.formatDate(r.end_date)}
+ ${r.reason || 'β'}
+ ${App.statusBadge(r.status)}
+ ${r.department || 'β'}
+ `).join('')}
+
`}
+
+
`;
+ } else {
+ const params = new URLSearchParams();
+ const empEl = document.getElementById('reportEmployee');
+ if (empEl && empEl.value) params.set('user_id', empEl.value);
+ params.set('year', document.getElementById('reportYear')?.value || new Date().getFullYear());
+ const data = await App.api(`/api/reports/summary?${params}`);
+ this.lastData = data;
+ const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+ const grouped = {};
+ data.forEach(r => {
+ const key = `${r.first_name} ${r.last_name}`;
+ if (!grouped[key]) grouped[key] = {};
+ const m = months[parseInt(r.month) - 1];
+ if (!grouped[key][m]) grouped[key][m] = {};
+ grouped[key][m][r.status] = (grouped[key][m][r.status] || 0) + parseInt(r.count);
+ });
+ container.innerHTML = `
+
+
+
+ ${data.length === 0 ? '
No records found
' :
+ Object.entries(grouped).map(([name, monthData]) => `
+
${name}
+
Month Approved Rejected Pending Total
+ ${Object.entries(monthData).map(([m, statuses]) => {
+ const a = statuses.approved || 0;
+ const r2 = statuses.rejected || 0;
+ const p = statuses.pending || 0;
+ return `${m} ${a} ${r2} ${p} ${a+r2+p} `;
+ }).join('')}
+
+ `).join('')}
+
+
`;
+ }
+ } catch (err) { container.innerHTML = `
${err.message}
`; }
+ },
+
+ exportCSV() {
+ if (!this.lastData || this.lastData.length === 0) { App.toast('Generate a report first', 'error'); return; }
+ const type = document.getElementById('reportType').value;
+ let csv, filename;
+ if (type === 'detailed') {
+ csv = 'Employee,Type,Start Date,End Date,Reason,Status,Department\n';
+ csv += this.lastData.map(r =>
+ `"${r.first_name} ${r.last_name}","${r.leave_type}","${App.formatDate(r.start_date)}","${App.formatDate(r.end_date)}","${(r.reason||'').replace(/"/g,'""')}","${r.status}","${r.department||''}"`
+ ).join('\n');
+ filename = 'detailed_report.csv';
+ } else {
+ csv = 'Employee,Month,Status,Count\n';
+ csv += this.lastData.map(r =>
+ `"${r.first_name} ${r.last_name}","${r.month}","${r.status}","${r.count}"`
+ ).join('\n');
+ filename = 'summary_report.csv';
+ }
+ const blob = new Blob([csv], { type: 'text/csv' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = filename;
+ a.click();
+ App.toast('Report exported');
+ }
+};
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..4288279
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,74 @@
+-- βββ CYPHER-HR Database Schema βββ
+-- Run: psql -U KENCYPHER -d "cypher-hr" -f schema.sql
+
+CREATE TABLE IF NOT EXISTS company_profile (
+ id SERIAL PRIMARY KEY,
+ company_name VARCHAR(255) NOT NULL,
+ address TEXT,
+ phone VARCHAR(30),
+ email VARCHAR(255),
+ website VARCHAR(255),
+ industry VARCHAR(100),
+ established_date DATE,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS users (
+ id SERIAL PRIMARY KEY,
+ email VARCHAR(255) UNIQUE NOT NULL,
+ password VARCHAR(255) NOT NULL,
+ first_name VARCHAR(100) NOT NULL,
+ last_name VARCHAR(100) NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'employee',
+ department VARCHAR(100),
+ position VARCHAR(100),
+ phone VARCHAR(30),
+ avatar_color VARCHAR(10) DEFAULT '#6366f1',
+ is_active BOOLEAN DEFAULT true,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS leave_policies (
+ id SERIAL PRIMARY KEY,
+ leave_type VARCHAR(60) NOT NULL UNIQUE,
+ monthly_limit INTEGER NOT NULL DEFAULT 1,
+ is_enabled BOOLEAN DEFAULT true,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS leave_balances (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ leave_policy_id INTEGER NOT NULL REFERENCES leave_policies(id) ON DELETE CASCADE,
+ balance INTEGER NOT NULL DEFAULT 0,
+ month INTEGER NOT NULL,
+ year INTEGER NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW(),
+ UNIQUE(user_id, leave_policy_id, month, year)
+);
+
+CREATE TABLE IF NOT EXISTS leave_requests (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ leave_policy_id INTEGER NOT NULL REFERENCES leave_policies(id) ON DELETE CASCADE,
+ start_date DATE NOT NULL,
+ end_date DATE NOT NULL,
+ reason TEXT,
+ status VARCHAR(20) NOT NULL DEFAULT 'pending',
+ admin_remarks TEXT,
+ approved_by INTEGER REFERENCES users(id),
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_leave_balances_user ON leave_balances(user_id);
+CREATE INDEX IF NOT EXISTS idx_leave_balances_period ON leave_balances(month, year);
+CREATE INDEX IF NOT EXISTS idx_leave_requests_user ON leave_requests(user_id);
+CREATE INDEX IF NOT EXISTS idx_leave_requests_status ON leave_requests(status);
+CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
diff --git a/seed.sql b/seed.sql
new file mode 100644
index 0000000..9ce2ed4
--- /dev/null
+++ b/seed.sql
@@ -0,0 +1,24 @@
+-- βββ CYPHER-HR Seed Data βββ
+-- Run after schema.sql, passwords are bcrypt hashed for "admin123" and "emp123"
+
+-- Company Profile
+INSERT INTO company_profile (company_name, address, phone, email, website, industry)
+VALUES ('Cypher Technologies', '123 Innovation Drive, Tech City', '+1-555-0100', 'hr@cypher.tech', 'https://cypher.tech', 'Technology')
+ON CONFLICT DO NOTHING;
+
+-- Leave Policies
+INSERT INTO leave_policies (leave_type, monthly_limit, is_enabled, description) VALUES
+('Casual Leave', 2, true, 'General personal leave'),
+('Half Day', 4, true, 'Half day leave'),
+('Medical', 2, true, 'Medical/sick leave'),
+('Hajj', 1, false, 'Hajj pilgrimage leave'),
+('Umrah', 1, false, 'Umrah pilgrimage leave'),
+('Christmas', 1, true, 'Christmas holiday'),
+('Easter', 1, true, 'Easter holiday'),
+('Halloween', 1, false, 'Halloween holiday'),
+('Marriage', 1, true, 'Marriage leave')
+ON CONFLICT (leave_type) DO NOTHING;
+
+-- NOTE: To seed users, run the app and use the setup wizard,
+-- or use bcrypt to hash passwords manually.
+-- The setup wizard handles admin creation and policy setup automatically.
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..f8ce8bc
--- /dev/null
+++ b/server.js
@@ -0,0 +1,398 @@
+require('dotenv').config();
+const express = require('express');
+const cors = require('cors');
+const path = require('path');
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const { query, initDatabase, resetMonthlyBalances } = require('./db_connection');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+const JWT_SECRET = process.env.JWT_SECRET || 'cypher-hr-secret';
+
+app.use(cors());
+app.use(express.json());
+app.use(express.static(path.join(__dirname)));
+app.use('/thesvg', express.static(path.join(__dirname, 'node_modules', '@thesvg', 'icons', 'dist')));
+
+/* βββ Middleware βββ */
+function authMiddleware(req, res, next) {
+ const token = req.headers.authorization?.split(' ')[1];
+ if (!token) return res.status(401).json({ error: 'No token provided' });
+ try {
+ req.user = jwt.verify(token, JWT_SECRET);
+ next();
+ } catch { return res.status(401).json({ error: 'Invalid token' }); }
+}
+
+function adminOnly(req, res, next) {
+ if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin access required' });
+ next();
+}
+
+/* βββ System Status βββ */
+app.get('/api/system/status', async (req, res) => {
+ try {
+ const company = await query('SELECT id FROM company_profile LIMIT 1');
+ const admin = await query("SELECT id FROM users WHERE role='admin' LIMIT 1");
+ const policies = await query('SELECT id FROM leave_policies LIMIT 1');
+ res.json({
+ hasCompany: company.rowCount > 0,
+ hasAdmin: admin.rowCount > 0,
+ hasPolicies: policies.rowCount > 0,
+ setupComplete: company.rowCount > 0 && admin.rowCount > 0 && policies.rowCount > 0
+ });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Auth Routes βββ */
+app.post('/api/auth/login', async (req, res) => {
+ try {
+ const { email, password } = req.body;
+ if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
+ const result = await query('SELECT * FROM users WHERE email=$1 AND is_active=true', [email.toLowerCase()]);
+ if (result.rowCount === 0) return res.status(401).json({ error: 'Invalid credentials' });
+ const user = result.rows[0];
+ const valid = await bcrypt.compare(password, user.password);
+ if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
+ const token = jwt.sign({ id: user.id, email: user.email, role: user.role, first_name: user.first_name, last_name: user.last_name }, JWT_SECRET, { expiresIn: '24h' });
+ res.json({ token, user: { id: user.id, email: user.email, role: user.role, first_name: user.first_name, last_name: user.last_name, department: user.department, position: user.position, avatar_color: user.avatar_color } });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.get('/api/auth/me', authMiddleware, async (req, res) => {
+ try {
+ const result = await query('SELECT id,email,first_name,last_name,role,department,position,phone,avatar_color,created_at FROM users WHERE id=$1', [req.user.id]);
+ if (result.rowCount === 0) return res.status(404).json({ error: 'User not found' });
+ res.json(result.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Setup Routes βββ */
+app.post('/api/setup/company', async (req, res) => {
+ try {
+ const existing = await query('SELECT id FROM company_profile LIMIT 1');
+ if (existing.rowCount > 0) return res.status(400).json({ error: 'Company already exists' });
+ const { company_name, address, phone, email, website, industry, established_date } = req.body;
+ if (!company_name) return res.status(400).json({ error: 'Company name is required' });
+ const result = await query(
+ 'INSERT INTO company_profile(company_name,address,phone,email,website,industry,established_date) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *',
+ [company_name, address, phone, email, website, industry, established_date]
+ );
+ res.json(result.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.post('/api/setup/admin', async (req, res) => {
+ try {
+ const existing = await query("SELECT id FROM users WHERE role='admin' LIMIT 1");
+ if (existing.rowCount > 0) return res.status(400).json({ error: 'Admin already exists' });
+ const { email, password, first_name, last_name, phone } = req.body;
+ if (!email || !password || !first_name || !last_name) return res.status(400).json({ error: 'All fields required' });
+ const hashed = await bcrypt.hash(password, 12);
+ const colors = ['#6366f1','#8b5cf6','#ec4899','#f43f5e','#14b8a6','#f97316','#06b6d4'];
+ const color = colors[Math.floor(Math.random() * colors.length)];
+ const result = await query(
+ "INSERT INTO users(email,password,first_name,last_name,role,department,position,phone,avatar_color) VALUES($1,$2,$3,$4,'admin','Human Resources','HR Administrator',$5,$6) RETURNING id,email,first_name,last_name,role,avatar_color",
+ [email.toLowerCase(), hashed, first_name, last_name, phone, color]
+ );
+ res.json(result.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.post('/api/setup/policies', async (req, res) => {
+ try {
+ const { policies } = req.body;
+ if (!policies || !Array.isArray(policies)) return res.status(400).json({ error: 'Policies array required' });
+ const results = [];
+ for (const p of policies) {
+ const r = await query(
+ 'INSERT INTO leave_policies(leave_type,monthly_limit,is_enabled,description) VALUES($1,$2,$3,$4) ON CONFLICT(leave_type) DO UPDATE SET monthly_limit=$2,is_enabled=$3,description=$4 RETURNING *',
+ [p.leave_type, p.monthly_limit || 1, p.is_enabled !== false, p.description || '']
+ );
+ results.push(r.rows[0]);
+ }
+ res.json(results);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Company βββ */
+app.get('/api/company', authMiddleware, async (req, res) => {
+ try {
+ const r = await query('SELECT * FROM company_profile LIMIT 1');
+ res.json(r.rows[0] || null);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/company', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { company_name, address, phone, email, website, industry } = req.body;
+ const r = await query(
+ 'UPDATE company_profile SET company_name=$1,address=$2,phone=$3,email=$4,website=$5,industry=$6,updated_at=NOW() WHERE id=(SELECT id FROM company_profile LIMIT 1) RETURNING *',
+ [company_name, address, phone, email, website, industry]
+ );
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Leave Policies βββ */
+app.get('/api/policies', authMiddleware, async (req, res) => {
+ try {
+ const r = await query('SELECT * FROM leave_policies ORDER BY id');
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/policies/:id', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { monthly_limit, is_enabled } = req.body;
+ const r = await query('UPDATE leave_policies SET monthly_limit=$1,is_enabled=$2,updated_at=NOW() WHERE id=$3 RETURNING *', [monthly_limit, is_enabled, req.params.id]);
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Employee Management βββ */
+app.get('/api/employees', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const r = await query("SELECT id,email,first_name,last_name,role,department,position,phone,avatar_color,is_active,created_at FROM users WHERE role='employee' ORDER BY first_name");
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.post('/api/employees', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { email, password, first_name, last_name, department, position, phone } = req.body;
+ if (!email || !password || !first_name || !last_name) return res.status(400).json({ error: 'Required fields missing' });
+ const exists = await query('SELECT id FROM users WHERE email=$1', [email.toLowerCase()]);
+ if (exists.rowCount > 0) return res.status(400).json({ error: 'Email already in use' });
+ const hashed = await bcrypt.hash(password, 12);
+ const colors = ['#6366f1','#8b5cf6','#ec4899','#f43f5e','#14b8a6','#f97316','#06b6d4','#84cc16','#eab308'];
+ const color = colors[Math.floor(Math.random() * colors.length)];
+ const r = await query(
+ "INSERT INTO users(email,password,first_name,last_name,role,department,position,phone,avatar_color) VALUES($1,$2,$3,$4,'employee',$5,$6,$7,$8) RETURNING id,email,first_name,last_name,role,department,position,phone,avatar_color",
+ [email.toLowerCase(), hashed, first_name, last_name, department, position, phone, color]
+ );
+ const user = r.rows[0];
+ const now = new Date();
+ const policies = await query('SELECT id,monthly_limit FROM leave_policies WHERE is_enabled=true');
+ for (const p of policies.rows) {
+ await query('INSERT INTO leave_balances(user_id,leave_policy_id,balance,month,year) VALUES($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING',
+ [user.id, p.id, p.monthly_limit, now.getMonth()+1, now.getFullYear()]);
+ }
+ res.json(user);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.delete('/api/employees/:id', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ await query('DELETE FROM users WHERE id=$1 AND role=$2', [req.params.id, 'employee']);
+ res.json({ success: true });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/employees/:id/toggle', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const r = await query('UPDATE users SET is_active = NOT is_active, updated_at=NOW() WHERE id=$1 RETURNING is_active', [req.params.id]);
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/employees/:id', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { email, first_name, last_name, department, position, phone } = req.body;
+ if (!email || !first_name || !last_name) return res.status(400).json({ error: 'Name and email are required' });
+ const exists = await query('SELECT id FROM users WHERE email=$1 AND id!=$2', [email.toLowerCase(), req.params.id]);
+ if (exists.rowCount > 0) return res.status(400).json({ error: 'Email already in use by another user' });
+ const r = await query(
+ 'UPDATE users SET email=$1,first_name=$2,last_name=$3,department=$4,position=$5,phone=$6,updated_at=NOW() WHERE id=$7 AND role=$8 RETURNING id,email,first_name,last_name,department,position,phone,avatar_color',
+ [email.toLowerCase(), first_name, last_name, department || null, position || null, phone || null, req.params.id, 'employee']
+ );
+ if (r.rowCount === 0) return res.status(404).json({ error: 'Employee not found' });
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/employees/:id/reset-password', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { new_password } = req.body;
+ if (!new_password || new_password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' });
+ const hashed = await bcrypt.hash(new_password, 12);
+ const r = await query('UPDATE users SET password=$1,updated_at=NOW() WHERE id=$2 AND role=$3 RETURNING id', [hashed, req.params.id, 'employee']);
+ if (r.rowCount === 0) return res.status(404).json({ error: 'Employee not found' });
+ res.json({ success: true, message: 'Password reset successfully' });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Leave Balances βββ */
+app.get('/api/balances', authMiddleware, async (req, res) => {
+ try {
+ const uid = req.user.role === 'admin' && req.query.user_id ? req.query.user_id : req.user.id;
+ const month = parseInt(req.query.month) || new Date().getMonth()+1;
+ const year = parseInt(req.query.year) || new Date().getFullYear();
+ const r = await query(
+ `SELECT lb.*, lp.leave_type, lp.monthly_limit, lp.is_enabled
+ FROM leave_balances lb JOIN leave_policies lp ON lb.leave_policy_id=lp.id
+ WHERE lb.user_id=$1 AND lb.month=$2 AND lb.year=$3 ORDER BY lp.leave_type`,
+ [uid, month, year]
+ );
+ if (r.rowCount === 0) {
+ const policies = await query('SELECT id,monthly_limit,leave_type,is_enabled FROM leave_policies WHERE is_enabled=true');
+ const balances = [];
+ for (const p of policies.rows) {
+ const ins = await query('INSERT INTO leave_balances(user_id,leave_policy_id,balance,month,year) VALUES($1,$2,$3,$4,$5) ON CONFLICT(user_id,leave_policy_id,month,year) DO NOTHING RETURNING *', [uid, p.id, p.monthly_limit, month, year]);
+ balances.push({ ...ins.rows[0], leave_type: p.leave_type, monthly_limit: p.monthly_limit, is_enabled: p.is_enabled });
+ }
+ return res.json(balances);
+ }
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/balances/:id', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { balance } = req.body;
+ const r = await query('UPDATE leave_balances SET balance=$1,updated_at=NOW() WHERE id=$2 RETURNING *', [balance, req.params.id]);
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Leave Requests βββ */
+app.get('/api/leaves', authMiddleware, async (req, res) => {
+ try {
+ let sql, params;
+ if (req.user.role === 'admin') {
+ sql = `SELECT lr.*, lp.leave_type, u.first_name, u.last_name, u.email, u.avatar_color
+ FROM leave_requests lr JOIN leave_policies lp ON lr.leave_policy_id=lp.id
+ JOIN users u ON lr.user_id=u.id ORDER BY lr.created_at DESC`;
+ params = [];
+ } else {
+ sql = `SELECT lr.*, lp.leave_type FROM leave_requests lr JOIN leave_policies lp ON lr.leave_policy_id=lp.id
+ WHERE lr.user_id=$1 ORDER BY lr.created_at DESC`;
+ params = [req.user.id];
+ }
+ const r = await query(sql, params);
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.post('/api/leaves', authMiddleware, async (req, res) => {
+ try {
+ const { leave_policy_id, start_date, end_date, reason } = req.body;
+ if (!leave_policy_id || !start_date || !end_date) return res.status(400).json({ error: 'All fields required' });
+ const now = new Date();
+ const month = now.getMonth() + 1;
+ const year = now.getFullYear();
+ const bal = await query('SELECT * FROM leave_balances WHERE user_id=$1 AND leave_policy_id=$2 AND month=$3 AND year=$4', [req.user.id, leave_policy_id, month, year]);
+ if (bal.rowCount === 0 || bal.rows[0].balance <= 0) return res.status(400).json({ error: 'Insufficient leave balance' });
+ const policy = await query('SELECT * FROM leave_policies WHERE id=$1', [leave_policy_id]);
+ if (policy.rowCount === 0 || !policy.rows[0].is_enabled) return res.status(400).json({ error: 'Leave type not available' });
+ const s = new Date(start_date); const e = new Date(end_date);
+ let days = Math.ceil((e - s) / (1000*60*60*24)) + 1;
+ if (policy.rows[0].leave_type === 'Half Day') days = 0.5;
+ if (bal.rows[0].balance < days) return res.status(400).json({ error: `Insufficient balance. Available: ${bal.rows[0].balance}, Requested: ${days}` });
+ const r = await query(
+ 'INSERT INTO leave_requests(user_id,leave_policy_id,start_date,end_date,reason) VALUES($1,$2,$3,$4,$5) RETURNING *',
+ [req.user.id, leave_policy_id, start_date, end_date, reason]
+ );
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/leaves/:id/approve', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { admin_remarks } = req.body;
+ const lr = await query('SELECT * FROM leave_requests WHERE id=$1', [req.params.id]);
+ if (lr.rowCount === 0) return res.status(404).json({ error: 'Not found' });
+ if (lr.rows[0].status !== 'pending') return res.status(400).json({ error: 'Already processed' });
+ const leave = lr.rows[0];
+ const policy = await query('SELECT * FROM leave_policies WHERE id=$1', [leave.leave_policy_id]);
+ let days = Math.ceil((new Date(leave.end_date) - new Date(leave.start_date)) / (1000*60*60*24)) + 1;
+ if (policy.rows[0].leave_type === 'Half Day') days = 0.5;
+ const now = new Date();
+ await query('UPDATE leave_balances SET balance=balance-$1,updated_at=NOW() WHERE user_id=$2 AND leave_policy_id=$3 AND month=$4 AND year=$5',
+ [days, leave.user_id, leave.leave_policy_id, now.getMonth()+1, now.getFullYear()]);
+ const r = await query("UPDATE leave_requests SET status='approved',admin_remarks=$1,approved_by=$2,updated_at=NOW() WHERE id=$3 RETURNING *",
+ [admin_remarks, req.user.id, req.params.id]);
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.put('/api/leaves/:id/reject', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const { admin_remarks } = req.body;
+ const r = await query("UPDATE leave_requests SET status='rejected',admin_remarks=$1,approved_by=$2,updated_at=NOW() WHERE id=$3 RETURNING *",
+ [admin_remarks, req.user.id, req.params.id]);
+ res.json(r.rows[0]);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Reports βββ */
+app.get('/api/reports/detailed', authMiddleware, async (req, res) => {
+ try {
+ const { user_id, start_date, end_date, status } = req.query;
+ let sql = `SELECT lr.*, lp.leave_type, u.first_name, u.last_name, u.department
+ FROM leave_requests lr JOIN leave_policies lp ON lr.leave_policy_id=lp.id
+ JOIN users u ON lr.user_id=u.id WHERE 1=1`;
+ const params = [];
+ let i = 1;
+ if (req.user.role !== 'admin') { sql += ` AND lr.user_id=$${i++}`; params.push(req.user.id); }
+ else if (user_id) { sql += ` AND lr.user_id=$${i++}`; params.push(user_id); }
+ if (start_date) { sql += ` AND lr.start_date >= $${i++}`; params.push(start_date); }
+ if (end_date) { sql += ` AND lr.end_date <= $${i++}`; params.push(end_date); }
+ if (status) { sql += ` AND lr.status=$${i++}`; params.push(status); }
+ sql += ' ORDER BY lr.start_date DESC';
+ const r = await query(sql, params);
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.get('/api/reports/summary', authMiddleware, async (req, res) => {
+ try {
+ const { user_id, year } = req.query;
+ const yr = parseInt(year) || new Date().getFullYear();
+ let userFilter = '';
+ const params = [yr];
+ if (req.user.role !== 'admin') { userFilter = 'AND lr.user_id=$2'; params.push(req.user.id); }
+ else if (user_id) { userFilter = 'AND lr.user_id=$2'; params.push(user_id); }
+ const r = await query(
+ `SELECT EXTRACT(MONTH FROM lr.start_date) as month, lp.leave_type, COUNT(*) as count,
+ lr.status, u.first_name, u.last_name
+ FROM leave_requests lr JOIN leave_policies lp ON lr.leave_policy_id=lp.id
+ JOIN users u ON lr.user_id=u.id
+ WHERE EXTRACT(YEAR FROM lr.start_date)=$1 ${userFilter}
+ GROUP BY EXTRACT(MONTH FROM lr.start_date), lp.leave_type, lr.status, u.first_name, u.last_name
+ ORDER BY month`, params
+ );
+ res.json(r.rows);
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+app.get('/api/dashboard/stats', authMiddleware, adminOnly, async (req, res) => {
+ try {
+ const totalEmp = await query("SELECT COUNT(*) FROM users WHERE role='employee'");
+ const activeEmp = await query("SELECT COUNT(*) FROM users WHERE role='employee' AND is_active=true");
+ const pendingLeaves = await query("SELECT COUNT(*) FROM leave_requests WHERE status='pending'");
+ const approvedToday = await query("SELECT COUNT(*) FROM leave_requests WHERE status='approved' AND DATE(updated_at)=CURRENT_DATE");
+ res.json({
+ totalEmployees: parseInt(totalEmp.rows[0].count),
+ activeEmployees: parseInt(activeEmp.rows[0].count),
+ pendingLeaves: parseInt(pendingLeaves.rows[0].count),
+ approvedToday: parseInt(approvedToday.rows[0].count)
+ });
+ } catch (err) { res.status(500).json({ error: err.message }); }
+});
+
+/* βββ Start Server βββ */
+async function start() {
+ try {
+ await initDatabase();
+ await resetMonthlyBalances();
+ setInterval(async () => {
+ const now = new Date();
+ if (now.getDate() === 1 && now.getHours() === 0) await resetMonthlyBalances();
+ }, 3600000);
+ app.listen(PORT, () => console.log(`CYPHER-HR running on http://localhost:${PORT}`));
+ } catch (err) { console.error('Failed to start:', err); process.exit(1); }
+}
+
+start();
diff --git a/setup.js b/setup.js
new file mode 100644
index 0000000..9de70ca
--- /dev/null
+++ b/setup.js
@@ -0,0 +1,174 @@
+/* βββ CYPHER-HR Setup Wizard Module βββ */
+const Setup = {
+ step: 1,
+ status: null,
+ defaultPolicies: [
+ { leave_type: 'Casual Leave', monthly_limit: 2, is_enabled: true, description: 'General personal leave' },
+ { leave_type: 'Half Day', monthly_limit: 4, is_enabled: true, description: 'Half day leave' },
+ { leave_type: 'Medical', monthly_limit: 2, is_enabled: true, description: 'Medical/sick leave' },
+ { leave_type: 'Hajj', monthly_limit: 1, is_enabled: false, description: 'Hajj pilgrimage leave' },
+ { leave_type: 'Umrah', monthly_limit: 1, is_enabled: false, description: 'Umrah pilgrimage leave' },
+ { leave_type: 'Christmas', monthly_limit: 1, is_enabled: true, description: 'Christmas holiday' },
+ { leave_type: 'Easter', monthly_limit: 1, is_enabled: true, description: 'Easter holiday' },
+ { leave_type: 'Halloween', monthly_limit: 1, is_enabled: false, description: 'Halloween holiday' },
+ { leave_type: 'Marriage', monthly_limit: 1, is_enabled: true, description: 'Marriage leave' },
+ ],
+
+ init(status) {
+ this.status = status;
+ if (status.hasCompany) this.step = 2;
+ if (status.hasAdmin) this.step = 3;
+ this.render();
+ },
+
+ render() {
+ App.setView(`
+
+
+
+
+
${this.step > 1 ? icon('check', 14) : '1'}
+
Company
+
+
+
+
${this.step > 2 ? icon('check', 14) : '2'}
+
Admin
+
+
+
+
+
${this.getStepContent()}
+
+ `);
+ },
+
+ getStepContent() {
+ if (this.step === 1) return `
+
${icon('building', 22)} Company Profile
+
+
+ Company Name *
+
+
+
+ Company Type *
+
+ Select company type
+ Technology
+ Healthcare
+ Finance & Banking
+ Education
+ Manufacturing
+ Retail & E-commerce
+ Consulting
+ Real Estate
+ Media & Entertainment
+ Non-Profit
+ Government
+ Other
+
+
+ Continue ${icon('chevronRight', 16)}
+
+ `;
+
+ if (this.step === 2) return `
+
${icon('shield', 22)} Create Admin Account
+
+
+ Email *
+ Password *
+ Continue ${icon('chevronRight', 16)}
+
+ `;
+
+ if (this.step === 3) return `
+
${icon('calendar', 22)} Leave Policies
+
Configure leave types and monthly limits
+
+
+ ${this.defaultPolicies.map((p, i) => `
+
+
+
+ Monthly Limit
+
+
+
+ `).join('')}
+
+ Complete Setup ${icon('check', 16)}
+
+ `;
+ },
+
+ async saveCompany(e) {
+ e.preventDefault();
+ const err = document.getElementById('setupError');
+ err.style.display = 'none';
+ try {
+ await App.api('/api/setup/company', {
+ method: 'POST',
+ body: JSON.stringify({
+ company_name: document.getElementById('companyName').value,
+ industry: document.getElementById('companyIndustry').value
+ })
+ });
+ this.step = 2;
+ this.render();
+ } catch (error) { err.textContent = error.message; err.style.display = 'block'; }
+ },
+
+ async saveAdmin(e) {
+ e.preventDefault();
+ const err = document.getElementById('setupError');
+ err.style.display = 'none';
+ try {
+ await App.api('/api/setup/admin', {
+ method: 'POST',
+ body: JSON.stringify({
+ first_name: document.getElementById('adminFirst').value,
+ last_name: document.getElementById('adminLast').value,
+ email: document.getElementById('adminEmail').value,
+ password: document.getElementById('adminPassword').value
+ })
+ });
+ this.step = 3;
+ this.render();
+ } catch (error) { err.textContent = error.message; err.style.display = 'block'; }
+ },
+
+ async savePolicies(e) {
+ e.preventDefault();
+ const err = document.getElementById('setupError');
+ err.style.display = 'none';
+ try {
+ const policies = this.defaultPolicies.map((p, i) => ({
+ leave_type: p.leave_type,
+ monthly_limit: parseInt(document.getElementById(`pol_lim_${i}`).value) || 1,
+ is_enabled: document.getElementById(`pol_en_${i}`).checked,
+ description: p.description
+ }));
+ await App.api('/api/setup/policies', { method: 'POST', body: JSON.stringify({ policies }) });
+ App.toast('Setup complete! Please log in.');
+ Login.show();
+ } catch (error) { err.textContent = error.message; err.style.display = 'block'; }
+ }
+};
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..3e898b9
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,287 @@
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+:root{
+ --bg:#f5f7fa;--bg2:#ffffff;--bg3:#f0f2f5;--bg4:#e4e7ec;
+ --surface:#ffffff;--border:#e2e5ea;--border2:#d0d5dd;
+ --text:#101828;--text2:#475467;--text3:#667085;--text4:#98a2b3;
+ --primary:#1570ef;--primary-light:#eff4ff;--primary-dark:#1849a9;--primary2:#2e90fa;
+ --success:#12b76a;--success-light:#ecfdf3;--danger:#f04438;--danger-light:#fef3f2;
+ --warning:#f79009;--warning-light:#fffaeb;--info:#0ba5ec;--info-light:#f0f9ff;
+ --purple:#7a5af8;--purple-light:#f4f3ff;
+ --radius:8px;--radius-lg:12px;--radius-xl:16px;
+ --shadow-xs:0 1px 2px rgba(16,24,40,0.05);
+ --shadow-sm:0 1px 3px rgba(16,24,40,0.1),0 1px 2px rgba(16,24,40,0.06);
+ --shadow-md:0 4px 8px -2px rgba(16,24,40,0.1),0 2px 4px -2px rgba(16,24,40,0.06);
+ --shadow-lg:0 12px 16px -4px rgba(16,24,40,0.08),0 4px 6px -2px rgba(16,24,40,0.03);
+}
+body.dark-mode{
+ --bg:#111827;--bg2:#1f2937;--bg3:#374151;--bg4:#4b5563;
+ --surface:#1f2937;--border:#374151;--border2:#4b5563;
+ --text:#f9fafb;--text2:#d1d5db;--text3:#9ca3af;--text4:#6b7280;
+ --primary:#3b82f6;--primary-light:#1e3a8a;--primary-dark:#60a5fa;--primary2:#2e90fa;
+ --success:#10b981;--success-light:#064e3b;--danger:#ef4444;--danger-light:#7f1d1d;
+ --warning:#f59e0b;--warning-light:#78350f;--info:#0ea5e9;--info-light:#0c4a6e;
+ --purple:#8b5cf6;--purple-light:#4c1d95;
+ --shadow-xs:0 1px 2px rgba(0,0,0,0.5);
+ --shadow-sm:0 1px 3px rgba(0,0,0,0.6),0 1px 2px rgba(0,0,0,0.4);
+ --shadow-md:0 4px 8px -2px rgba(0,0,0,0.6),0 2px 4px -2px rgba(0,0,0,0.4);
+ --shadow-lg:0 12px 16px -4px rgba(0,0,0,0.8),0 4px 6px -2px rgba(0,0,0,0.4);
+}
+body{font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh;-webkit-font-smoothing:antialiased}
+#app{min-height:100vh}
+a{color:var(--primary);text-decoration:none;cursor:pointer}
+input,select,textarea{font-family:inherit;font-size:0.875rem;padding:10px 14px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);color:var(--text);width:100%;outline:none;transition:all 0.15s;box-shadow:var(--shadow-xs)}
+input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 4px rgba(21,112,239,0.12)}
+input::placeholder,textarea::placeholder{color:var(--text4)}
+select{cursor:pointer;appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 24 24' fill='none' stroke='%2398a2b3' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center}
+label{display:block;font-size:0.8125rem;font-weight:500;color:var(--text2);margin-bottom:6px}
+.form-group{margin-bottom:1.25rem}
+.form-row{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
+.form-error{color:var(--danger);font-size:0.8125rem;margin-top:0.75rem;padding:10px 14px;background:var(--danger-light);border:1px solid #fecdca;border-radius:var(--radius);text-align:center}
+.form-actions{display:flex;gap:0.75rem;margin-top:1.25rem}
+
+/* ββ Buttons ββ */
+.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 18px;border:1px solid transparent;border-radius:var(--radius);font-weight:600;font-size:0.875rem;cursor:pointer;transition:all 0.15s;font-family:inherit;line-height:1.25;box-shadow:var(--shadow-xs)}
+.btn:hover{transform:translateY(-1px);box-shadow:var(--shadow-sm)}
+.btn:active{transform:translateY(0)}
+.btn:disabled{opacity:0.4;pointer-events:none}
+.btn-primary{background:var(--primary);color:#fff;border-color:var(--primary)}
+.btn-primary:hover{background:var(--primary-dark)}
+.btn-success{background:var(--success);color:#fff;border-color:var(--success)}
+.btn-danger{background:var(--danger);color:#fff;border-color:var(--danger)}
+.btn-outline{background:var(--bg2);border-color:var(--border);color:var(--text2)}
+.btn-outline:hover{border-color:var(--border2);color:var(--text);background:var(--bg3)}
+.btn-ghost{background:transparent;border:none;color:var(--text2);box-shadow:none;padding:8px}
+.btn-ghost:hover{background:var(--bg3);color:var(--text);box-shadow:none}
+.btn-full{width:100%;justify-content:center}
+.btn-sm{padding:6px 12px;font-size:0.8125rem;border-radius:6px}
+.btn-icon{background:none;border:none;color:var(--text3);cursor:pointer;padding:6px;display:inline-flex;align-items:center;box-shadow:none}
+.btn-icon:hover{color:var(--text);background:var(--bg3);border-radius:6px;box-shadow:none}
+.btn-icon-sm{background:var(--bg3);border:1px solid var(--border);color:var(--text2);width:32px;height:32px;border-radius:var(--radius);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all 0.15s;box-shadow:none}
+.btn-icon-sm:hover{border-color:var(--primary);color:var(--primary);background:var(--primary-light)}
+
+/* ββ Icons ββ */
+.icon{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
+.icon svg{width:100%;height:100%}
+.icon img{width:100%;height:100%;filter:brightness(0) saturate(100%)}
+
+/* ββ Avatar ββ */
+.avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:0.875rem;color:#fff;flex-shrink:0;letter-spacing:-0.5px}
+
+/* ββ Badge ββ */
+.badge{display:inline-flex;align-items:center;padding:2px 10px;border-radius:16px;font-size:0.75rem;font-weight:500;gap:4px}
+.badge-success{background:var(--success-light);color:var(--success)}
+.badge-danger{background:var(--danger-light);color:var(--danger)}
+.badge-warning{background:var(--warning-light);color:var(--warning)}
+.badge-info{background:var(--info-light);color:var(--info)}
+
+/* ββ Card ββ */
+.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-xs)}
+.card-header{padding:16px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
+.card-header h3{display:flex;align-items:center;gap:8px;font-size:0.9375rem;font-weight:600;color:var(--text)}
+.card-body{padding:24px}
+.card-body.compact{padding:0}
+
+/* ββ Table ββ */
+.table{width:100%;border-collapse:collapse}
+.table th{text-align:left;padding:12px 24px;font-size:0.75rem;text-transform:uppercase;letter-spacing:0.04em;color:var(--text3);background:var(--bg);border-bottom:1px solid var(--border);font-weight:500}
+.table td{padding:14px 24px;border-bottom:1px solid var(--border);font-size:0.875rem;vertical-align:middle;color:var(--text2)}
+.table tr:last-child td{border-bottom:none}
+.table tr:hover td{background:var(--bg)}
+.table-sm td,.table-sm th{padding:8px 16px}
+.user-cell{display:flex;align-items:center;gap:10px;color:var(--text);font-weight:500}
+.action-cell{display:flex;gap:6px;flex-wrap:wrap}
+.text-success{color:var(--success)}.text-danger{color:var(--danger)}.text-warning{color:var(--warning)}.text-muted{color:var(--text3);font-size:0.8125rem}
+.empty-state{text-align:center;color:var(--text3);padding:48px 24px;font-size:0.875rem}
+.error-state{text-align:center;color:var(--danger);padding:2rem}
+.section-title{font-size:0.875rem;font-weight:600;margin:24px 0 12px;color:var(--text);text-transform:uppercase;letter-spacing:0.04em}
+
+/* ββ Toast ββ */
+.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:var(--radius);font-size:0.875rem;font-weight:500;z-index:9999;transform:translateY(100px);opacity:0;transition:all 0.3s cubic-bezier(0.4,0,0.2,1);box-shadow:var(--shadow-lg);display:flex;align-items:center;gap:8px}
+.toast.show{transform:translateY(0);opacity:1}
+.toast-success{background:var(--success);color:#fff}
+.toast-error{background:var(--danger);color:#fff}
+
+/* ββ Modal ββ */
+.modal-overlay{position:fixed;inset:0;background:rgba(16,24,40,0.55);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;z-index:1000;opacity:0;transition:opacity 0.2s}
+.modal-overlay.show{opacity:1}
+.modal{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius-xl);width:90%;max-width:520px;max-height:85vh;overflow-y:auto;box-shadow:var(--shadow-lg)}
+.modal-header{padding:20px 24px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
+.modal-header h3{font-size:1.0625rem;font-weight:600}
+.modal-body{padding:24px}
+.modal-footer{padding:16px 24px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
+
+/* ββ Tabs ββ */
+.tabs{display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:0}
+.tab{padding:10px 16px;border:none;background:transparent;color:var(--text3);border-bottom:2px solid transparent;cursor:pointer;font-size:0.875rem;font-weight:500;font-family:inherit;transition:all 0.15s;margin-bottom:-1px}
+.tab:hover{color:var(--text2)}
+.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
+.tab-content{display:none}.tab-content.active{display:block}
+
+/* ββ Toggle Switch ββ */
+.switch{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}
+.switch input{opacity:0;width:0;height:0}
+.slider{position:absolute;inset:0;background:var(--bg4);border-radius:24px;cursor:pointer;transition:0.2s}
+.slider::before{content:'';position:absolute;width:20px;height:20px;left:2px;bottom:2px;background:#fff;border-radius:50%;transition:0.2s;box-shadow:var(--shadow-xs)}
+.switch input:checked+.slider{background:var(--primary)}
+.switch input:checked+.slider::before{transform:translateX(20px)}
+.input-sm{width:80px;padding:6px 10px;text-align:center}
+
+/* ββ Auth Layout ββ */
+.auth-container{display:grid;grid-template-columns:480px 1fr;min-height:100vh}
+.auth-left{background:linear-gradient(160deg,#1849a9 0%,#1570ef 40%,#2e90fa 100%);display:flex;flex-direction:column;justify-content:center;padding:60px;position:relative;overflow:hidden}
+.auth-left::before{content:'';position:absolute;top:-30%;right:-30%;width:80%;height:80%;background:radial-gradient(circle,rgba(255,255,255,0.08) 0%,transparent 70%);border-radius:50%}
+.auth-left::after{content:'';position:absolute;bottom:-20%;left:-20%;width:60%;height:60%;background:radial-gradient(circle,rgba(255,255,255,0.05) 0%,transparent 70%);border-radius:50%}
+.auth-brand{position:relative;z-index:1;margin-bottom:40px}
+.brand-logo{width:48px;height:48px;background:rgba(255,255,255,0.2);border-radius:12px;display:flex;align-items:center;justify-content:center;margin-bottom:24px;backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,0.1)}
+.brand-logo .icon{color:#fff}
+.auth-brand h1{font-size:2rem;font-weight:800;color:#fff;letter-spacing:-0.5px}
+.auth-brand p{color:rgba(255,255,255,0.7);font-size:1rem;margin-top:8px}
+.auth-features{display:flex;flex-direction:column;gap:16px;position:relative;z-index:1}
+.feature-item{display:flex;align-items:center;gap:12px;color:rgba(255,255,255,0.8);font-size:0.875rem;font-weight:500}
+.feature-dot{width:32px;height:32px;border-radius:8px;background:rgba(255,255,255,0.15);display:flex;align-items:center;justify-content:center;flex-shrink:0;backdrop-filter:blur(10px)}
+.feature-dot .icon{color:#fff}
+.auth-right{display:flex;align-items:center;justify-content:center;padding:40px;background:var(--bg)}
+.auth-card{width:100%;max-width:400px}
+.auth-card h2{font-size:1.5rem;font-weight:700;color:var(--text);letter-spacing:-0.3px}
+.auth-subtitle{color:var(--text3);margin-bottom:32px;font-size:0.875rem}
+
+/* ββ Setup Wizard ββ */
+.setup-container{max-width:560px;margin:0 auto;padding:48px 24px}
+.setup-header{text-align:center;margin-bottom:40px}
+.setup-header .brand-logo{margin:0 auto 16px}
+.setup-header h1{font-size:1.5rem;font-weight:700;color:var(--text);letter-spacing:-0.3px}
+.setup-header p{color:var(--text3);font-size:0.875rem;margin-top:4px}
+.setup-steps{display:flex;align-items:center;justify-content:center;margin-bottom:32px}
+.step{display:flex;flex-direction:column;align-items:center;gap:6px}
+.step-num{width:36px;height:36px;border-radius:50%;background:var(--bg3);border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-weight:600;font-size:0.8125rem;color:var(--text3);transition:all 0.2s}
+.step.active .step-num{border-color:var(--primary);background:var(--primary);color:#fff}
+.step.done .step-num{border-color:var(--success);background:var(--success);color:#fff}
+.step span{font-size:0.75rem;color:var(--text3);font-weight:500}
+.step-line{width:64px;height:2px;background:var(--border);margin:0 8px;margin-bottom:20px}
+.step-line.done{background:var(--success)}
+.setup-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-xl);padding:32px;box-shadow:var(--shadow-sm)}
+.setup-card h2{display:flex;align-items:center;gap:10px;font-size:1.125rem;margin-bottom:24px;font-weight:600;color:var(--text)}
+
+/* ββ Policies Grid ββ */
+.policies-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+.policy-card{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:14px}
+.policy-header{display:flex;align-items:center;gap:10px;margin-bottom:10px}
+.policy-name{font-weight:600;font-size:0.875rem;color:var(--text)}
+.policy-body{display:flex;align-items:center;gap:8px}
+.policy-body label{margin-bottom:0;font-size:0.75rem}
+
+/* ββ App Layout ββ */
+.app-layout{display:grid;grid-template-columns:260px 1fr;min-height:100vh}
+.sidebar{background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;position:sticky;top:0;height:100vh;overflow-y:auto}
+.sidebar-brand{padding:20px 20px;display:flex;align-items:center;gap:12px;font-weight:700;font-size:1rem;color:var(--text);border-bottom:1px solid var(--border)}
+.sidebar-brand .brand-logo{width:36px;height:36px;background:var(--primary);border-radius:10px;display:flex;align-items:center;justify-content:center}
+.sidebar-brand .brand-logo .icon{color:#fff}
+.sidebar-nav{flex:1;padding:12px;display:flex;flex-direction:column;gap:2px}
+.nav-item{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:var(--radius);color:var(--text3);font-size:0.875rem;font-weight:500;cursor:pointer;transition:all 0.15s;border:none;background:none;text-align:left;width:100%}
+.nav-item:hover{background:var(--bg);color:var(--text)}
+.nav-item.active{background:var(--primary-light);color:var(--primary)}
+.nav-item .icon{opacity:0.7}
+.nav-item.active .icon{opacity:1}
+.sidebar-footer{padding:12px;border-top:1px solid var(--border)}
+.user-info{display:flex;align-items:center;gap:10px;padding:10px 8px;margin-bottom:4px}
+.user-info strong{display:block;font-size:0.8125rem;color:var(--text);font-weight:600}
+.user-info small{color:var(--text3);font-size:0.75rem}
+.main-content{display:flex;flex-direction:column;min-height:100vh;background:var(--bg)}
+.topbar{padding:16px 32px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;background:var(--bg2)}
+.topbar h2{font-size:1.125rem;font-weight:600;color:var(--text)}
+.greeting{color:var(--text3);font-size:0.8125rem}
+.content-area{padding:32px;flex:1;transition:opacity 0.15s}
+.content-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;gap:12px;flex-wrap:wrap}
+.search-box{display:flex;align-items:center;gap:8px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:0 14px;min-width:280px;box-shadow:var(--shadow-xs)}
+.search-box input{border:none;background:transparent;padding:9px 0;box-shadow:none}
+.search-box input:focus{border:none;box-shadow:none}
+.search-box .icon{color:var(--text4)}
+
+/* ββ Stats ββ */
+.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
+.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px 24px;display:flex;align-items:center;gap:16px;box-shadow:var(--shadow-xs);transition:all 0.2s}
+.stat-card:hover{box-shadow:var(--shadow-md);transform:translateY(-1px)}
+.stat-icon{width:48px;height:48px;border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center}
+.stat-purple .stat-icon{background:var(--purple-light);color:var(--purple)}
+.stat-green .stat-icon{background:var(--success-light);color:var(--success)}
+.stat-amber .stat-icon{background:var(--warning-light);color:var(--warning)}
+.stat-blue .stat-icon{background:var(--info-light);color:var(--info)}
+.stat-num{font-size:1.75rem;font-weight:700;color:var(--text);display:block;line-height:1}
+.stat-label{font-size:0.8125rem;color:var(--text3);margin-top:2px}
+
+/* ββ Employee Cards ββ */
+.employees-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
+.employee-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-xs);transition:all 0.2s}
+.employee-card:hover{box-shadow:var(--shadow-md);border-color:var(--primary)}
+.emp-header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
+.emp-header h4{font-size:0.9375rem;font-weight:600;color:var(--text)}
+.emp-header .text-muted{display:block;margin-top:1px}
+.emp-header .badge{margin-left:auto}
+.emp-details{display:flex;gap:20px;margin-bottom:14px;color:var(--text3);font-size:0.8125rem}
+.emp-detail{display:flex;align-items:center;gap:4px}
+.emp-actions{display:flex;gap:8px;flex-wrap:wrap;padding-top:12px;border-top:1px solid var(--border)}
+
+/* ββ Employee Profile ββ */
+.emp-profile-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;display:flex;align-items:center;justify-content:space-between;margin-bottom:24px;flex-wrap:wrap;gap:16px;box-shadow:var(--shadow-xs)}
+.emp-profile-info{display:flex;align-items:center;gap:16px}
+.emp-profile-info .avatar{width:52px;height:52px;font-size:1rem}
+.emp-profile-info h3{font-size:1.125rem;font-weight:700;color:var(--text)}
+.emp-profile-info p{color:var(--text3);font-size:0.8125rem}
+.emp-quick-stats{display:flex;gap:32px}
+.quick-stat{text-align:center}
+.qs-num{display:block;font-size:1.5rem;font-weight:700;color:var(--primary)}
+.qs-label{font-size:0.6875rem;color:var(--text3);text-transform:uppercase;letter-spacing:0.04em;font-weight:500}
+
+/* ββ Balances ββ */
+.balances-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-bottom:24px}
+.balance-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px;box-shadow:var(--shadow-xs)}
+.balance-card-header{display:flex;justify-content:space-between;margin-bottom:8px;font-size:0.8125rem;font-weight:500;color:var(--text)}
+.balance-fraction{font-weight:700}
+.progress-bar{height:6px;background:var(--bg4);border-radius:3px;overflow:hidden}
+.progress-fill{height:100%;border-radius:3px;transition:width 0.5s ease}
+.balances-list{display:flex;flex-direction:column;gap:8px}
+.balance-row{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border)}
+.balance-type{font-weight:500;font-size:0.875rem;color:var(--text)}
+.balance-control{display:flex;align-items:center;gap:8px}
+.balance-num{font-weight:700;font-size:1.125rem;min-width:30px;text-align:center;color:var(--text)}
+
+/* ββ Policies Manage ββ */
+.policies-manage{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
+.policy-manage-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-xs)}
+.policy-manage-header{display:flex;align-items:center;gap:12px;margin-bottom:14px}
+.policy-manage-header h4{font-size:0.9375rem;font-weight:600;color:var(--text)}
+.policy-manage-header small{display:block;color:var(--text3);font-size:0.75rem}
+.policy-manage-limit{display:flex;align-items:center;gap:8px;padding-top:12px;border-top:1px solid var(--border)}
+.policy-manage-limit label{margin-bottom:0}
+
+/* ββ Custom Date Picker ββ */
+.custom-datepicker { position: relative; }
+.datepicker-input-wrapper { position: relative; }
+.datepicker-icon { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); color: var(--text4); pointer-events: none; }
+.datepicker-popup { position: absolute; top: 100%; left: 0; margin-top: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-lg); z-index: 1000; width: 280px; padding: 16px; display: none; }
+.datepicker-popup.show { display: block; animation: slideDown 0.2s ease-out; }
+@keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
+.datepicker-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
+.datepicker-title { font-weight: 600; font-size: 0.9375rem; color: var(--text); }
+.datepicker-nav { background: transparent; border: none; cursor: pointer; color: var(--text3); padding: 4px; border-radius: 4px; transition: all 0.15s; display: flex; align-items: center; justify-content: center; }
+.datepicker-nav:hover { background: var(--bg3); color: var(--text); }
+.datepicker-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
+.datepicker-day-header { text-align: center; font-size: 0.75rem; font-weight: 600; color: var(--text4); padding-bottom: 8px; text-transform: uppercase; }
+.datepicker-day { padding: 6px 0; text-align: center; font-size: 0.875rem; color: var(--text); cursor: pointer; border-radius: 4px; transition: all 0.15s; }
+.datepicker-day:hover:not(.empty) { background: var(--primary-light); color: var(--primary-dark); font-weight: 500; }
+.datepicker-day.selected { background: var(--primary); color: #fff; font-weight: 600; box-shadow: var(--shadow-xs); }
+.datepicker-day.today { border: 1px solid var(--primary); color: var(--primary); font-weight: 600; }
+.datepicker-day.empty { visibility: hidden; }
+
+/* ββ Reports ββ */
+.reports-container{max-width:900px}
+
+/* ββ Responsive ββ */
+@media(max-width:900px){
+ .app-layout{grid-template-columns:1fr}.sidebar{display:none}
+ .auth-container{grid-template-columns:1fr}.auth-left{display:none}
+ .stats-grid{grid-template-columns:1fr 1fr}.form-row{grid-template-columns:1fr}
+ .policies-grid{grid-template-columns:1fr}.content-area{padding:16px}
+}
+@media(max-width:600px){.stats-grid{grid-template-columns:1fr}}