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 @@ +
+ +Shield Logo + +

CYPHER-HR

+ +

+ The Fully Transparent, Free, & Open-Source HR Management System +

+ +

+ Designed & Developed by KENCYPHER +

+ +

+ Status Active + License MIT + Node JS + PostgreSQL + Vanilla JS +

+ +--- + +
+ +
+ +## 🌟 The Vision & Motivation + +> *"I created CYPHER-HR because, working in industries and corporations, I faced a recurring issue: **employees didn't know their own leaves and reports**. This critical data was often hidden from them, creating confusion, frustration, and an imbalance of power. I believe everyone should have the right to transparently see their own leavesβ€”how much they have used, what is remaining, and their historical data. Transparency builds trust, and trust builds great companies."* +> +> β€” **KENCYPHER** + +
+ +## 🐧 The Open Source Philosophy (100% Free) + +This is **NOT** a SaaS platform designed to extract monthly fees from growing businesses. In the spirit of true open-source development and drawing inspiration from the philosophy of **Linus Torvalds**, CYPHER-HR is entirely **FREE** and built for the community. + +Software that governs people's daily lives and working conditions shouldn't be locked behind enterprise paywalls. It should be open, auditable, modifiable, and accessible to everyone. You have full control over the source code, your data, and your infrastructure. Fork it, improve it, and host it yourself forever. + +
+ +--- + +## ✨ Comprehensive Feature Suite + +CYPHER-HR is built as an enterprise-grade Single Page Application (SPA), rivaling expensive proprietary solutions without the bloat. + +
+

πŸ‘₯ For Employees

+
+ +- **Total Transparency:** An interactive dashboard displaying pending, approved, and total leave requests at a glance. +- **Leave Allowances:** Real-time progress bars showing exactly how many leaves of each type are available vs. consumed. +- **Seamless Applications:** A beautifully crafted form to apply for leave, complete with a custom corporate date picker that strictly formats dates as `DD:MM:YYYY`. +- **Complete History:** View past applications, statuses, and administrative remarks without needing to ask HR. +
+ +
+

πŸ‘” For Administrators & HR

+
+ +- **Centralized Employee Directory:** Add, edit, deactivate, or delete employees securely. +- **Password Management:** Instantly reset employee passwords if they get locked out. +- **Granular Leave Controls:** Create custom leave policies (e.g., Sick, Casual, Maternity) and set exact monthly limits. +- **Direct Allowance Adjustments:** Manually increment or decrement individual employee leave balances when necessary. +- **Automated Monthly Resets:** A built-in backend Cron Job automatically refreshes all enabled leave allowances at midnight on the 1st of every month. +
+ +
+

πŸ“Š Advanced Reporting & Exporting

+
+ +- **Dynamic Data Filtering:** Query employee leave requests by date range, specific employee, or status (pending, approved, rejected). +- **PDF Generation:** Instantly generate professional, styled PDF reports with tabular data using `jsPDF` and `jspdf-autotable`. +- **CSV Export:** Download raw data into perfectly formatted `.csv` files for Excel or Google Sheets manipulation. +
+ +
+

🎨 State-of-the-Art UI/UX

+
+ +- **Custom Built Design System:** No massive UI frameworks (like Bootstrap or Tailwind). The entire corporate aesthetic is crafted in pure, lightweight CSS featuring glassmorphism, soft shadows, and clean typography. +- **Global Dark Mode:** A native dark mode toggle providing a deep, high-contrast palette for low-light environments. +- **Dynamic Iconography:** Leveraging the highly optimized `@thesvg/icons` library to fetch and render crisp vector graphics seamlessly. +- **Custom Date Picker Engine:** Bypasses ugly native browser inputs with a fully custom, interactive calendar dropdown built in Vanilla JS. +
+ +
+ +--- + +## πŸ—οΈ Deep-Dive Architecture + +The application utilizes a **Vanilla SPA (Single Page Application)** architecture on the frontend, communicating with a **Node.js/Express** REST API, backed by a fully normalized **PostgreSQL** database. + +
+ + + + +
+ +
+ +### πŸ“‚ Detailed File Guide + +Want to modify the code? Here is exactly what every file in the project does: + +#### πŸ–₯️ Server & Database Layer +| File | Description | +| :--- | :--- | +| **`server.js`** | The heart of the backend. It spins up the Express server, provides RESTful endpoints, handles JWT authentication/middleware, and runs the `node-cron` scheduler for monthly balance resets. | +| **`db_connection.js`** | Manages the `pg` connection pool. Ensures queries to PostgreSQL are handled efficiently and handles database connection errors gracefully. | +| **`schema.sql`** | The relational database blueprint. It creates the `kencypher` role, builds tables for `users`, `leave_policies`, `leave_balances`, and `leave_requests`, and establishes foreign key constraints. | +| **`seed.sql`** | Optional script to populate the database with dummy data for immediate testing and demonstration. | + +#### 🌐 Frontend Core & UI Layer +| File | Description | +| :--- | :--- | +| **`index.html`** | The unified HTML shell. It imports CDNs (jsPDF), Google Fonts (Inter), and all JavaScript modules. The entire app mounts inside the `
` 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 ` +
+ +
+
+

${navItems.find(n=>n.id===active)?.label || 'Dashboard'}

+
+ + Hello, ${App.user.first_name}! +
+
+
${content}
+
+
`; + }, + + 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
+
+
+

Recent Pending Requests

+
+ ${pending.length === 0 ? '

No pending requests

' : + ` + ${pending.map(l => ` + + + + + + `).join('')} +
EmployeeLeave TypeDatesStatusActions
${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)} + + +
`} +
+
`; + } 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 => ` +
+
+ ${App.avatar(emp)} +
+

${emp.first_name} ${emp.last_name}

+ ${emp.email} +
+ ${emp.is_active ? 'Active' : 'Inactive'} +
+
+
${icon('briefcase',14)} ${emp.department || 'β€”'}
+
${icon('calendar',14)} ${emp.position || 'β€”'}
+
+
+ + + + + +
+
`).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', ` +
+
+
+
+
+
+
+
+
+
+
+
+ +
`, + `` + ); + }, + + 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', ` +
+
+
+
+
+
+
+
+
+
+
+ +
`, + ` + ` + ); + }, + + 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}

+
+
+
+ +
`, + ` + ` + ); + }, + + 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} +
+ + ${b.balance} + +
+ / ${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 => ` +
+
+ +
+

${p.leave_type}

+ ${p.description || ''} +
+
+
+ + +
+
`).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 ` +
+
+ +
${icon('calendar', 16)}
+
+
+
`; +} + +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 = ` +
+ +
${months[month]} ${year}
+ +
+
+
Su
Mo
Tu
+
We
Th
Fr
Sa
+ `; + + 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 ` +
+ +
+
+

${navItems.find(n=>n.id===active)?.label || 'Dashboard'}

+
+ + Hello, ${App.user.first_name}! +
+
+
${content}
+
+
`; + }, + + 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 ` +
+
+ ${b.leave_type} + ${b.balance}/${b.monthly_limit} +
+
+
`; + }).join('')} +
+ +

Recent Requests

+
+
+ ${recent.length === 0 ? '

No leave requests yet

' : + ` + ${recent.map(l => ` + + + + + `).join('')} +
TypeDatesReasonStatus
${l.leave_type}${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}${l.reason || 'β€”'}${App.statusBadge(l.status)}
`} +
+
`; + } 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 ` +
+

${icon('calendar', 20)} Apply for Leave

+
+
+
+ + +
+
+
${dateInput('leaveStart', true)}
+
${dateInput('leaveEnd', true)}
+
+
+ + +
+
+
`; + } 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 ` +
+

All Leave Requests

+
+ ${leaves.length === 0 ? '

No leave history

' : + ` + ${leaves.map(l => ` + + + + + + + `).join('')} +
TypeStartEndReasonStatusRemarks
${l.leave_type}${App.formatDate(l.start_date)}${App.formatDate(l.end_date)}${l.reason || 'β€”'}${App.statusBadge(l.status)}${l.admin_remarks || 'β€”'}
`} +
+
`; + } 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 + + + + +
Loading CYPHER-HR...
+ + + + + + + + + + + + + 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.length === 0 ? '

No pending requests

' : + ` + ${pending.map(l => ` + + + + + + + `).join('')} +
EmployeeTypeStartEndReasonActions
${App.avatar(l)}${l.first_name} ${l.last_name}
${l.leave_type}${App.formatDate(l.start_date)}${App.formatDate(l.end_date)}${l.reason || 'β€”'} + + +
`} +
+
+ ${processed.length === 0 ? '

No processed requests

' : + ` + ${processed.map(l => ` + + + + + + `).join('')} +
EmployeeTypeDatesStatusRemarks
${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 || 'β€”'}
`} +
`; + } 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', ` +
+
+ +
+ `); + }, + + 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', ` +
+
+ +
+ `); + }, + + 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(` +
+
+
+ +

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

+
+
+ + +
+
+ + +
+ + +
+
+
+
+ `); + }, + + 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 = ` + `; + 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('report', 20)} Generate Report

+
+
+
+ + +
+
+ + +
+
+
+
${dateInput('reportStart')}
+
${dateInput('reportEnd')}
+
+
+
+ + +
+
+ + +
+
+
+ + + +
+
+
+
+
`; + } catch (err) { return `
${err.message}
`; } + }, + + async employeeView() { + return ` +
+
+

${icon('report', 20)} My Reports

+
+
+
+ + +
+
+ + +
+
+
+
${dateInput('reportStart')}
+
${dateInput('reportEnd')}
+
+
+ + + +
+
+
+
+
`; + }, + + 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 = ` +
+

Detailed Report (${data.length} records)

+
+ ${data.length === 0 ? '

No records found

' : + ` + ${data.map(r => ` + + + + + + + + `).join('')} +
EmployeeTypeStartEndReasonStatusDepartment
${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 || 'β€”'}
`} +
+
`; + } 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 = ` +
+

Summary Report β€” ${document.getElementById('reportYear')?.value || new Date().getFullYear()}

+
+ ${data.length === 0 ? '

No records found

' : + Object.entries(grouped).map(([name, monthData]) => ` +

${name}

+ + ${Object.entries(monthData).map(([m, statuses]) => { + const a = statuses.approved || 0; + const r2 = statuses.rejected || 0; + const p = statuses.pending || 0; + return ``; + }).join('')} +
MonthApprovedRejectedPendingTotal
${m}${a}${r2}${p}${a+r2+p}
+ `).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(` +
+
+ +

CYPHER-HR

+

Let's set up your HR system

+
+
+
+
${this.step > 1 ? icon('check', 14) : '1'}
+ Company +
+
+
+
${this.step > 2 ? icon('check', 14) : '2'}
+ Admin +
+
+
+
3
+ Policies +
+
+
${this.getStepContent()}
+
+ `); + }, + + getStepContent() { + if (this.step === 1) return ` +

${icon('building', 22)} Company Profile

+
+
+ + +
+
+ + +
+ + +
`; + + if (this.step === 2) return ` +

${icon('shield', 22)} Create Admin Account

+
+
+
+
+
+
+
+ + +
`; + + if (this.step === 3) return ` +

${icon('calendar', 22)} Leave Policies

+

Configure leave types and monthly limits

+
+
+ ${this.defaultPolicies.map((p, i) => ` +
+
+ + ${p.leave_type} +
+
+ + +
+
+ `).join('')} +
+ + +
`; + }, + + 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}}