NEW RELEASE
This commit is contained in:
89
.gitignore
vendored
Normal file
89
.gitignore
vendored
Normal file
@@ -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
|
||||
226
README.md
Normal file
226
README.md
Normal file
@@ -0,0 +1,226 @@
|
||||
<div align="center">
|
||||
|
||||
<img src="https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/shield-halved.svg" width="80" height="80" alt="Shield Logo">
|
||||
|
||||
<h1 style="color: #1570ef; font-size: 3.5em; margin-bottom: 0;">CYPHER-HR</h1>
|
||||
|
||||
<p style="font-size: 1.5em; color: #475467; font-weight: 500;">
|
||||
The Fully Transparent, Free, & Open-Source HR Management System
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Designed & Developed by KENCYPHER</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Status-Active-success?style=for-the-badge" alt="Status Active" />
|
||||
<img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License MIT" />
|
||||
<img src="https://img.shields.io/badge/Node.js-v16%2B-green?style=for-the-badge&logo=node.js" alt="Node JS" />
|
||||
<img src="https://img.shields.io/badge/PostgreSQL-v12%2B-informational?style=for-the-badge&logo=postgresql" alt="PostgreSQL" />
|
||||
<img src="https://img.shields.io/badge/Vanilla_JS-100%25-yellow?style=for-the-badge&logo=javascript" alt="Vanilla JS" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## 🌟 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**
|
||||
|
||||
<br />
|
||||
|
||||
## 🐧 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.
|
||||
|
||||
<br />
|
||||
|
||||
---
|
||||
|
||||
## ✨ Comprehensive Feature Suite
|
||||
|
||||
CYPHER-HR is built as an enterprise-grade Single Page Application (SPA), rivaling expensive proprietary solutions without the bloat.
|
||||
|
||||
<details open>
|
||||
<summary><h3 style="display:inline-block; margin:0;">👥 For Employees</h3></summary>
|
||||
<br>
|
||||
|
||||
- **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.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h3 style="display:inline-block; margin:0;">👔 For Administrators & HR</h3></summary>
|
||||
<br>
|
||||
|
||||
- **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.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h3 style="display:inline-block; margin:0;">📊 Advanced Reporting & Exporting</h3></summary>
|
||||
<br>
|
||||
|
||||
- **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.
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><h3 style="display:inline-block; margin:0;">🎨 State-of-the-Art UI/UX</h3></summary>
|
||||
<br>
|
||||
|
||||
- **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.
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 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.
|
||||
|
||||
<div align="center">
|
||||
<img src="https://img.shields.io/badge/Architecture-Client%2FServer-purple?style=for-the-badge" />
|
||||
<img src="https://img.shields.io/badge/Frontend-Vanilla_JS_%2B_DOM_API-yellow?style=for-the-badge" />
|
||||
<img src="https://img.shields.io/badge/Backend-Node_Express-green?style=for-the-badge" />
|
||||
<img src="https://img.shields.io/badge/Database-PostgreSQL-blue?style=for-the-badge" />
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
### 📂 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 `<div id="app">` 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. |
|
||||
|
||||
<br />
|
||||
|
||||
---
|
||||
|
||||
## 🚀 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:
|
||||
<kbd>psql -U postgres</kbd>
|
||||
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:
|
||||
<kbd>psql -U kencypher -d cypher_hr_db -f schema.sql</kbd>
|
||||
|
||||
### 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!
|
||||
|
||||
<br />
|
||||
|
||||
---
|
||||
|
||||
## 🤝 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: <kbd>git checkout -b feature/amazing-new-feature</kbd>
|
||||
3. Commit your changes: <kbd>git commit -m 'Add amazing new feature'</kbd>
|
||||
4. Push to the branch: <kbd>git push origin feature/amazing-new-feature</kbd>
|
||||
5. Open a **Pull Request**.
|
||||
|
||||
All contributions, bug reports, and feature requests are highly welcome. Let's build the ultimate free HR tool together.
|
||||
|
||||
<br />
|
||||
|
||||
## 📜 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.
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<p><b>Built with passion and a commitment to transparency by KENCYPHER.</b></p>
|
||||
<p><i>Give this repository a ⭐ if it helped your business!</i></p>
|
||||
</div>
|
||||
363
admin.js
Normal file
363
admin.js
Normal file
@@ -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 `
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-logo">${icon('shield', 20)}</div>
|
||||
<span>CYPHER-HR</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
${navItems.map(n => `
|
||||
<a class="nav-item ${active === n.id ? 'active' : ''}" data-view="${n.id}">
|
||||
${icon(n.icon, 20)}<span>${n.label}</span>
|
||||
</a>`).join('')}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
${App.avatar(App.user)}
|
||||
<div><strong>${App.user.first_name} ${App.user.last_name}</strong><small>Administrator</small></div>
|
||||
</div>
|
||||
<a class="nav-item" onclick="App.logout()">${icon('logout', 20)}<span>Logout</span></a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<header class="topbar">
|
||||
<h2>${navItems.find(n=>n.id===active)?.label || 'Dashboard'}</h2>
|
||||
<div class="topbar-right">
|
||||
<button class="btn btn-icon-sm" id="themeToggleBtn" onclick="App.toggleTheme()" style="margin-right:12px">${icon(App.theme === 'light' ? 'moon' : 'sun', 18)}</button>
|
||||
<span class="greeting">Hello, ${App.user.first_name}!</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content-area" id="contentArea">${content}</div>
|
||||
</main>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
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 `
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stat-purple"><div class="stat-icon">${icon('users', 28)}</div><div class="stat-info"><span class="stat-num">${stats.totalEmployees}</span><span class="stat-label">Total Employees</span></div></div>
|
||||
<div class="stat-card stat-green"><div class="stat-icon">${icon('check', 28)}</div><div class="stat-info"><span class="stat-num">${stats.activeEmployees}</span><span class="stat-label">Active Employees</span></div></div>
|
||||
<div class="stat-card stat-amber"><div class="stat-icon">${icon('clock', 28)}</div><div class="stat-info"><span class="stat-num">${stats.pendingLeaves}</span><span class="stat-label">Pending Requests</span></div></div>
|
||||
<div class="stat-card stat-blue"><div class="stat-icon">${icon('calendar', 28)}</div><div class="stat-info"><span class="stat-num">${stats.approvedToday}</span><span class="stat-label">Approved Today</span></div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>Recent Pending Requests</h3></div>
|
||||
<div class="card-body">
|
||||
${pending.length === 0 ? '<p class="empty-state">No pending requests</p>' :
|
||||
`<table class="table"><thead><tr><th>Employee</th><th>Leave Type</th><th>Dates</th><th>Status</th><th>Actions</th></tr></thead><tbody>
|
||||
${pending.map(l => `<tr>
|
||||
<td><div class="user-cell">${App.avatar(l)}<span>${l.first_name} ${l.last_name}</span></div></td>
|
||||
<td>${l.leave_type}</td>
|
||||
<td>${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}</td>
|
||||
<td>${App.statusBadge(l.status)}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn btn-sm btn-success" onclick="Leaves.approve(${l.id})">${icon('check',14)} Approve</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Leaves.reject(${l.id})">${icon('x',14)} Reject</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">Error loading dashboard: ${err.message}</div>`; }
|
||||
},
|
||||
|
||||
async employeesContent() {
|
||||
try {
|
||||
const employees = await App.api('/api/employees');
|
||||
return `
|
||||
<div class="content-header">
|
||||
<div class="search-box">${icon('search',18)}<input type="text" placeholder="Search employees..." oninput="Admin.filterEmployees(this.value)"></div>
|
||||
<button class="btn btn-primary" onclick="Admin.showAddEmployee()">${icon('plus',18)} Add Employee</button>
|
||||
</div>
|
||||
<div class="employees-grid" id="employeesGrid">
|
||||
${employees.length === 0 ? '<p class="empty-state">No employees yet. Add your first employee!</p>' :
|
||||
employees.map(emp => `
|
||||
<div class="employee-card" data-name="${emp.first_name} ${emp.last_name}">
|
||||
<div class="emp-header">
|
||||
${App.avatar(emp)}
|
||||
<div>
|
||||
<h4>${emp.first_name} ${emp.last_name}</h4>
|
||||
<span class="text-muted">${emp.email}</span>
|
||||
</div>
|
||||
<span class="badge ${emp.is_active ? 'badge-success' : 'badge-danger'}">${emp.is_active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
<div class="emp-details">
|
||||
<div class="emp-detail">${icon('briefcase',14)} ${emp.department || '—'}</div>
|
||||
<div class="emp-detail">${icon('calendar',14)} ${emp.position || '—'}</div>
|
||||
</div>
|
||||
<div class="emp-actions">
|
||||
<button class="btn btn-sm btn-outline" onclick='Admin.showEditEmployee(${JSON.stringify(emp).replace(/'/g,"'")})'>${icon('edit',14)} Edit</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="Admin.showResetPassword(${emp.id},'${emp.first_name} ${emp.last_name}')">${icon('lock',14)} Reset Pwd</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="Admin.showBalances(${emp.id},'${emp.first_name} ${emp.last_name}')">${icon('calendar',14)} Leave Allowances</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="Admin.toggleEmployee(${emp.id})">${emp.is_active ? icon('x',14)+' Deactivate' : icon('check',14)+' Activate'}</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Admin.deleteEmployee(${emp.id},'${emp.first_name}')">${icon('trash',14)}</button>
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
filterEmployees(q) {
|
||||
document.querySelectorAll('.employee-card').forEach(c => {
|
||||
c.style.display = c.dataset.name.toLowerCase().includes(q.toLowerCase()) ? '' : 'none';
|
||||
});
|
||||
},
|
||||
|
||||
showAddEmployee() {
|
||||
showModal('Add Employee', `
|
||||
<form id="addEmpForm" onsubmit="Admin.addEmployee(event)">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>First Name *</label><input type="text" id="empFirst" required></div>
|
||||
<div class="form-group"><label>Last Name *</label><input type="text" id="empLast" required></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Email *</label><input type="email" id="empEmail" required></div>
|
||||
<div class="form-group"><label>Password *</label><input type="password" id="empPassword" required minlength="6"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Department</label><input type="text" id="empDept"></div>
|
||||
<div class="form-group"><label>Position</label><input type="text" id="empPos"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Phone</label><input type="tel" id="empPhone"></div>
|
||||
<div id="addEmpError" class="form-error" style="display:none"></div>
|
||||
</form>`,
|
||||
`<button class="btn btn-primary" onclick="document.getElementById('addEmpForm').requestSubmit()">Add Employee</button>`
|
||||
);
|
||||
},
|
||||
|
||||
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', `
|
||||
<form id="editEmpForm" onsubmit="Admin.editEmployee(event, ${emp.id})">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>First Name *</label><input type="text" id="editFirst" value="${emp.first_name}" required></div>
|
||||
<div class="form-group"><label>Last Name *</label><input type="text" id="editLast" value="${emp.last_name}" required></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Email *</label><input type="email" id="editEmail" value="${emp.email}" required></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Department</label><input type="text" id="editDept" value="${emp.department || ''}"></div>
|
||||
<div class="form-group"><label>Position</label><input type="text" id="editPos" value="${emp.position || ''}"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Phone</label><input type="tel" id="editPhone" value="${emp.phone || ''}"></div>
|
||||
<div id="editEmpError" class="form-error" style="display:none"></div>
|
||||
</form>`,
|
||||
`<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('editEmpForm').requestSubmit()">Save Changes</button>`
|
||||
);
|
||||
},
|
||||
|
||||
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', `
|
||||
<p style="margin-bottom:16px;color:var(--text2)">Set a new password for <strong>${name}</strong></p>
|
||||
<form id="resetPwdForm" onsubmit="Admin.resetPassword(event, ${id})">
|
||||
<div class="form-group"><label>New Password *</label><input type="password" id="newPassword" required minlength="6" placeholder="Min 6 characters"></div>
|
||||
<div class="form-group"><label>Confirm Password *</label><input type="password" id="confirmPassword" required minlength="6" placeholder="Re-enter password"></div>
|
||||
<div id="resetPwdError" class="form-error" style="display:none"></div>
|
||||
</form>`,
|
||||
`<button class="btn btn-outline" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="document.getElementById('resetPwdForm').requestSubmit()">Reset Password</button>`
|
||||
);
|
||||
},
|
||||
|
||||
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}`, `
|
||||
<div class="balances-list">
|
||||
${balances.map(b => `
|
||||
<div class="balance-row">
|
||||
<span class="balance-type">${b.leave_type}</span>
|
||||
<div class="balance-control">
|
||||
<button class="btn btn-icon-sm" onclick="Admin.adjustBalance(${b.id},-1,${userId},'${name}')">${icon('arrowDown',14)}</button>
|
||||
<span class="balance-num">${b.balance}</span>
|
||||
<button class="btn btn-icon-sm" onclick="Admin.adjustBalance(${b.id},1,${userId},'${name}')">${icon('arrowUp',14)}</button>
|
||||
</div>
|
||||
<span class="text-muted">/ ${b.monthly_limit} max</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
`);
|
||||
} 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 `
|
||||
<div class="policies-manage">
|
||||
${policies.map(p => `
|
||||
<div class="policy-manage-card">
|
||||
<div class="policy-manage-header">
|
||||
<label class="switch">
|
||||
<input type="checkbox" ${p.is_enabled ? 'checked' : ''} onchange="Admin.togglePolicy(${p.id}, this.checked, ${p.monthly_limit})">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<div>
|
||||
<h4>${p.leave_type}</h4>
|
||||
<small class="text-muted">${p.description || ''}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="policy-manage-limit">
|
||||
<label>Monthly Limit</label>
|
||||
<input type="number" value="${p.monthly_limit}" min="0" max="30" class="input-sm"
|
||||
onchange="Admin.updatePolicyLimit(${p.id}, this.value, ${p.is_enabled})">
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
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'); }
|
||||
}
|
||||
};
|
||||
154
db_connection.js
Normal file
154
db_connection.js
Normal file
@@ -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 };
|
||||
232
design.js
Normal file
232
design.js
Normal file
@@ -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: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>`,
|
||||
users: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||
calendar: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`,
|
||||
report: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
|
||||
settings: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
||||
logout: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>`,
|
||||
plus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||||
check: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`,
|
||||
x: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
|
||||
clock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
||||
briefcase: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>`,
|
||||
building: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><path d="M9 22v-4h6v4"/><line x1="8" y1="6" x2="8.01" y2="6"/><line x1="16" y1="6" x2="16.01" y2="6"/><line x1="12" y1="6" x2="12.01" y2="6"/><line x1="8" y1="10" x2="8.01" y2="10"/><line x1="16" y1="10" x2="16.01" y2="10"/><line x1="12" y1="10" x2="12.01" y2="10"/><line x1="8" y1="14" x2="8.01" y2="14"/><line x1="16" y1="14" x2="16.01" y2="14"/><line x1="12" y1="14" x2="12.01" y2="14"/></svg>`,
|
||||
shield: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||||
edit: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`,
|
||||
trash: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>`,
|
||||
search: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`,
|
||||
chevronRight: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>`,
|
||||
home: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
|
||||
download: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
|
||||
arrowUp: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>`,
|
||||
arrowDown: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>`,
|
||||
userPlus: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>`,
|
||||
fileText: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`,
|
||||
checkCircle: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>`,
|
||||
mail: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>`,
|
||||
lock: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
|
||||
moon: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
|
||||
sun: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>`,
|
||||
};
|
||||
|
||||
function icon(name, size = 20) {
|
||||
const defaultSvg = SVG_ICONS[name] || SVG_ICONS.dashboard;
|
||||
|
||||
if (iconCache[name] && iconCache[name] !== 'loading') {
|
||||
return `<span class="icon" style="width:${size}px;height:${size}px">${iconCache[name]}</span>`;
|
||||
}
|
||||
|
||||
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 `<span class="icon thesvg-icon" data-icon="${name}" style="width:${size}px;height:${size}px">${defaultSvg}</span>`;
|
||||
}
|
||||
|
||||
/* ═══ 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 `
|
||||
<div class="custom-datepicker" id="${id}_wrapper">
|
||||
<div class="datepicker-input-wrapper">
|
||||
<input type="text" id="${id}" placeholder="${placeholder}" ${req} maxlength="10" autocomplete="off"
|
||||
oninput="formatDateInput(this)" onfocus="openDatepicker('${id}')" onclick="openDatepicker('${id}')">
|
||||
<div class="datepicker-icon">${icon('calendar', 16)}</div>
|
||||
</div>
|
||||
<div class="datepicker-popup" id="${id}_popup" onmousedown="event.preventDefault()" onclick="event.stopPropagation()"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div class="datepicker-header">
|
||||
<button type="button" class="datepicker-nav" onclick="changeMonth('${id}', ${month - 1}, ${year})">${icon('chevronRight', 16)}</button>
|
||||
<div class="datepicker-title">${months[month]} ${year}</div>
|
||||
<button type="button" class="datepicker-nav" onclick="changeMonth('${id}', ${month + 1}, ${year})">${icon('chevronRight', 16)}</button>
|
||||
</div>
|
||||
<div class="datepicker-grid">
|
||||
<div class="datepicker-day-header">Su</div><div class="datepicker-day-header">Mo</div><div class="datepicker-day-header">Tu</div>
|
||||
<div class="datepicker-day-header">We</div><div class="datepicker-day-header">Th</div><div class="datepicker-day-header">Fr</div><div class="datepicker-day-header">Sa</div>
|
||||
`;
|
||||
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
html += `<div class="datepicker-day empty"></div>`;
|
||||
}
|
||||
|
||||
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 += `<div class="${classes}" onclick="selectDate('${id}', ${i}, ${month}, ${year})">${i}</div>`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
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();
|
||||
}
|
||||
208
employee.js
Normal file
208
employee.js
Normal file
@@ -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 `
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-logo">${icon('shield', 20)}</div>
|
||||
<span>CYPHER-HR</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
${navItems.map(n => `
|
||||
<a class="nav-item ${active === n.id ? 'active' : ''}" data-view="${n.id}">
|
||||
${icon(n.icon, 20)}<span>${n.label}</span>
|
||||
</a>`).join('')}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
${App.avatar(App.user)}
|
||||
<div><strong>${App.user.first_name} ${App.user.last_name}</strong><small>${App.user.position || 'Employee'}</small></div>
|
||||
</div>
|
||||
<a class="nav-item" onclick="App.logout()">${icon('logout', 20)}<span>Logout</span></a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<header class="topbar">
|
||||
<h2>${navItems.find(n=>n.id===active)?.label || 'Dashboard'}</h2>
|
||||
<div class="topbar-right">
|
||||
<button class="btn btn-icon-sm" id="themeToggleBtn" onclick="App.toggleTheme()" style="margin-right:12px">${icon(App.theme === 'light' ? 'moon' : 'sun', 18)}</button>
|
||||
<span class="greeting">Hello, ${App.user.first_name}!</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content-area" id="contentArea">${content}</div>
|
||||
</main>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
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 `
|
||||
<div class="emp-profile-card">
|
||||
<div class="emp-profile-info">
|
||||
${App.avatar(App.user)}
|
||||
<div>
|
||||
<h3>${App.user.first_name} ${App.user.last_name}</h3>
|
||||
<p>${App.user.position || ''} ${App.user.department ? '• ' + App.user.department : ''}</p>
|
||||
<p class="text-muted">${App.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="emp-quick-stats">
|
||||
<div class="quick-stat"><span class="qs-num">${pending}</span><span class="qs-label">Pending</span></div>
|
||||
<div class="quick-stat"><span class="qs-num">${approved}</span><span class="qs-label">Approved</span></div>
|
||||
<div class="quick-stat"><span class="qs-num">${leaves.length}</span><span class="qs-label">Total</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Leave Allowances</h3>
|
||||
<div class="balances-grid">
|
||||
${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 `
|
||||
<div class="balance-card">
|
||||
<div class="balance-card-header">
|
||||
<span>${b.leave_type}</span>
|
||||
<span class="balance-fraction" style="color:${color}">${b.balance}/${b.monthly_limit}</span>
|
||||
</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${color}"></div></div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Recent Requests</h3>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
${recent.length === 0 ? '<p class="empty-state">No leave requests yet</p>' :
|
||||
`<table class="table"><thead><tr><th>Type</th><th>Dates</th><th>Reason</th><th>Status</th></tr></thead><tbody>
|
||||
${recent.map(l => `<tr>
|
||||
<td>${l.leave_type}</td>
|
||||
<td>${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}</td>
|
||||
<td>${l.reason || '—'}</td>
|
||||
<td>${App.statusBadge(l.status)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
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 `
|
||||
<div class="card" style="max-width:600px">
|
||||
<div class="card-header"><h3>${icon('calendar', 20)} Apply for Leave</h3></div>
|
||||
<div class="card-body">
|
||||
<form id="applyForm" onsubmit="Employee.submitLeave(event)">
|
||||
<div class="form-group">
|
||||
<label>Leave Type</label>
|
||||
<select id="leaveType" required>
|
||||
<option value="">Select leave type</option>
|
||||
${enabled.map(p => {
|
||||
const bal = balances.find(b => b.leave_policy_id === p.id);
|
||||
const remaining = bal ? bal.balance : 0;
|
||||
return `<option value="${p.id}" ${remaining <= 0 ? 'disabled' : ''}>${p.leave_type} (${remaining} remaining)</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="position:relative"><label>Start Date</label>${dateInput('leaveStart', true)}</div>
|
||||
<div class="form-group" style="position:relative"><label>End Date</label>${dateInput('leaveEnd', true)}</div>
|
||||
</div>
|
||||
<div class="form-group"><label>Reason</label><textarea id="leaveReason" rows="3" placeholder="Describe your reason..."></textarea></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Submit Request ${icon('chevronRight', 18)}</button>
|
||||
<div id="applyError" class="form-error" style="display:none"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
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 `
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>All Leave Requests</h3></div>
|
||||
<div class="card-body">
|
||||
${leaves.length === 0 ? '<p class="empty-state">No leave history</p>' :
|
||||
`<table class="table"><thead><tr><th>Type</th><th>Start</th><th>End</th><th>Reason</th><th>Status</th><th>Remarks</th></tr></thead><tbody>
|
||||
${leaves.map(l => `<tr>
|
||||
<td>${l.leave_type}</td>
|
||||
<td>${App.formatDate(l.start_date)}</td>
|
||||
<td>${App.formatDate(l.end_date)}</td>
|
||||
<td>${l.reason || '—'}</td>
|
||||
<td>${App.statusBadge(l.status)}</td>
|
||||
<td>${l.admin_remarks || '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
}
|
||||
};
|
||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CYPHER-HR — Human Resource Management</title>
|
||||
<meta name="description" content="Enterprise HR Management System by Cypher">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#98a2b3;font-family:Inter,sans-serif">Loading CYPHER-HR...</div></div>
|
||||
<script src="design.js"></script>
|
||||
<script src="main.js"></script>
|
||||
<script src="login.js"></script>
|
||||
<script src="setup.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="employee.js"></script>
|
||||
<script src="leaves.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
|
||||
<script src="pdf_reports.js"></script>
|
||||
<script src="reports.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
94
leaves.js
Normal file
94
leaves.js
Normal file
@@ -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 `
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="Leaves.switchTab('pending', this)">Pending (${pending.length})</button>
|
||||
<button class="tab" onclick="Leaves.switchTab('processed', this)">Processed (${processed.length})</button>
|
||||
</div>
|
||||
<div id="tabPending" class="tab-content active">
|
||||
${pending.length === 0 ? '<p class="empty-state" style="padding:2rem">No pending requests</p>' :
|
||||
`<table class="table"><thead><tr><th>Employee</th><th>Type</th><th>Start</th><th>End</th><th>Reason</th><th>Actions</th></tr></thead><tbody>
|
||||
${pending.map(l => `<tr>
|
||||
<td><div class="user-cell">${App.avatar(l)}<span>${l.first_name} ${l.last_name}</span></div></td>
|
||||
<td>${l.leave_type}</td>
|
||||
<td>${App.formatDate(l.start_date)}</td>
|
||||
<td>${App.formatDate(l.end_date)}</td>
|
||||
<td>${l.reason || '—'}</td>
|
||||
<td class="action-cell">
|
||||
<button class="btn btn-sm btn-success" onclick="Leaves.approve(${l.id})">${icon('check',14)} Approve</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="Leaves.reject(${l.id})">${icon('x',14)} Reject</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>
|
||||
<div id="tabProcessed" class="tab-content">
|
||||
${processed.length === 0 ? '<p class="empty-state" style="padding:2rem">No processed requests</p>' :
|
||||
`<table class="table"><thead><tr><th>Employee</th><th>Type</th><th>Dates</th><th>Status</th><th>Remarks</th></tr></thead><tbody>
|
||||
${processed.map(l => `<tr>
|
||||
<td><div class="user-cell">${App.avatar(l)}<span>${l.first_name} ${l.last_name}</span></div></td>
|
||||
<td>${l.leave_type}</td>
|
||||
<td>${App.formatDate(l.start_date)} - ${App.formatDate(l.end_date)}</td>
|
||||
<td>${App.statusBadge(l.status)}</td>
|
||||
<td>${l.admin_remarks || '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
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', `
|
||||
<form onsubmit="Leaves.processApprove(event, ${id})">
|
||||
<div class="form-group"><label>Remarks (optional)</label><textarea id="approveRemarks" rows="3" placeholder="Add remarks..."></textarea></div>
|
||||
<button type="submit" class="btn btn-success btn-full">${icon('check',18)} Confirm Approval</button>
|
||||
</form>
|
||||
`);
|
||||
},
|
||||
|
||||
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', `
|
||||
<form onsubmit="Leaves.processReject(event, ${id})">
|
||||
<div class="form-group"><label>Reason for rejection</label><textarea id="rejectRemarks" rows="3" placeholder="Provide reason..." required></textarea></div>
|
||||
<button type="submit" class="btn btn-danger btn-full">${icon('x',18)} Confirm Rejection</button>
|
||||
</form>
|
||||
`);
|
||||
},
|
||||
|
||||
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'); }
|
||||
}
|
||||
};
|
||||
66
login.js
Normal file
66
login.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/* ═══ CYPHER-HR Login Module ═══ */
|
||||
const Login = {
|
||||
show() {
|
||||
App.setView(`
|
||||
<div class="auth-container">
|
||||
<div class="auth-left">
|
||||
<div class="auth-brand">
|
||||
<div class="brand-logo">${icon('shield', 24)}</div>
|
||||
<h1>CYPHER-HR</h1>
|
||||
<p>Enterprise Human Resource Management</p>
|
||||
</div>
|
||||
<div class="auth-features">
|
||||
<div class="feature-item"><div class="feature-dot">${icon('checkCircle', 16)}</div>Streamlined Leave Management</div>
|
||||
<div class="feature-item"><div class="feature-dot">${icon('report', 16)}</div>Real-time Analytics & Reports</div>
|
||||
<div class="feature-item"><div class="feature-dot">${icon('lock', 16)}</div>Role-based Access Control</div>
|
||||
<div class="feature-item"><div class="feature-dot">${icon('clock', 16)}</div>Automated Balance Tracking</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auth-right">
|
||||
<div class="auth-card">
|
||||
<h2>Welcome back</h2>
|
||||
<p class="auth-subtitle">Sign in to your HR portal</p>
|
||||
<form id="loginForm" onsubmit="Login.handleLogin(event)">
|
||||
<div class="form-group">
|
||||
<label>Email Address</label>
|
||||
<input type="email" id="loginEmail" placeholder="you@company.com" required autocomplete="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input type="password" id="loginPassword" placeholder="Enter your password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" id="loginBtn">
|
||||
Sign In ${icon('chevronRight', 16)}
|
||||
</button>
|
||||
<div id="loginError" class="form-error" style="display:none"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
},
|
||||
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
119
main.js
Normal file
119
main.js
Normal file
@@ -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)} <span>${msg}</span>`;
|
||||
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 `<span class="badge badge-${map[status] || 'info'}">${status.charAt(0).toUpperCase()+status.slice(1)}</span>`;
|
||||
},
|
||||
|
||||
avatar(user) {
|
||||
const c = user.avatar_color || '#1570ef';
|
||||
const initials = (user.first_name?.[0] || '') + (user.last_name?.[0] || '');
|
||||
return `<div class="avatar" style="background:${c}">${initials.toUpperCase()}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
/* ═══ Modal Utility ═══ */
|
||||
function showModal(title, content, footer = '') {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>${title}</h3><button class="btn-icon" onclick="closeModal()">${icon('x')}</button></div>
|
||||
<div class="modal-body">${content}</div>
|
||||
${footer ? `<div class="modal-footer">${footer}</div>` : ''}
|
||||
</div>`;
|
||||
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());
|
||||
23
package.json
Normal file
23
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
134
pdf_reports.js
Normal file
134
pdf_reports.js
Normal file
@@ -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');
|
||||
}
|
||||
};
|
||||
191
reports.js
Normal file
191
reports.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/* ═══ CYPHER-HR Reports Module ═══ */
|
||||
const Reports = {
|
||||
async adminView() {
|
||||
try {
|
||||
const employees = await App.api('/api/employees');
|
||||
return `
|
||||
<div class="reports-container">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>${icon('report', 20)} Generate Report</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Report Type</label>
|
||||
<select id="reportType">
|
||||
<option value="detailed">Detailed Report</option>
|
||||
<option value="summary">Summary Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Employee</label>
|
||||
<select id="reportEmployee">
|
||||
<option value="">All Employees</option>
|
||||
${employees.map(e => `<option value="${e.id}">${e.first_name} ${e.last_name}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="position:relative"><label>Start Date</label>${dateInput('reportStart')}</div>
|
||||
<div class="form-group" style="position:relative"><label>End Date</label>${dateInput('reportEnd')}</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Status</label>
|
||||
<select id="reportStatus">
|
||||
<option value="">All</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Year (Summary)</label>
|
||||
<input type="number" id="reportYear" value="${new Date().getFullYear()}" min="2020" max="2030">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" onclick="Reports.generate()">${icon('search', 18)} Generate</button>
|
||||
<button class="btn btn-outline" onclick="Reports.exportCSV()">${icon('download', 18)} Export CSV</button>
|
||||
<button class="btn btn-outline" onclick="PdfReports.export()">${icon('fileText', 18)} Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reportResults"></div>
|
||||
</div>`;
|
||||
} catch (err) { return `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
async employeeView() {
|
||||
return `
|
||||
<div class="reports-container">
|
||||
<div class="card">
|
||||
<div class="card-header"><h3>${icon('report', 20)} My Reports</h3></div>
|
||||
<div class="card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Report Type</label>
|
||||
<select id="reportType">
|
||||
<option value="detailed">Detailed Report</option>
|
||||
<option value="summary">Summary Report</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Year (Summary)</label>
|
||||
<input type="number" id="reportYear" value="${new Date().getFullYear()}" min="2020" max="2030">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="position:relative"><label>Start Date</label>${dateInput('reportStart')}</div>
|
||||
<div class="form-group" style="position:relative"><label>End Date</label>${dateInput('reportEnd')}</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" onclick="Reports.generate()">${icon('search', 18)} Generate</button>
|
||||
<button class="btn btn-outline" onclick="Reports.exportCSV()">${icon('download', 18)} Export CSV</button>
|
||||
<button class="btn btn-outline" onclick="PdfReports.export()">${icon('fileText', 18)} Export PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reportResults"></div>
|
||||
</div>`;
|
||||
},
|
||||
|
||||
lastData: [],
|
||||
|
||||
async generate() {
|
||||
const type = document.getElementById('reportType').value;
|
||||
const container = document.getElementById('reportResults');
|
||||
container.innerHTML = '<p class="text-muted" style="padding:1rem">Loading...</p>';
|
||||
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 = `
|
||||
<div class="card" style="margin-top:1rem">
|
||||
<div class="card-header"><h3>Detailed Report (${data.length} records)</h3></div>
|
||||
<div class="card-body">
|
||||
${data.length === 0 ? '<p class="empty-state">No records found</p>' :
|
||||
`<table class="table"><thead><tr><th>Employee</th><th>Type</th><th>Start</th><th>End</th><th>Reason</th><th>Status</th><th>Department</th></tr></thead><tbody>
|
||||
${data.map(r => `<tr>
|
||||
<td>${r.first_name} ${r.last_name}</td>
|
||||
<td>${r.leave_type}</td>
|
||||
<td>${App.formatDate(r.start_date)}</td>
|
||||
<td>${App.formatDate(r.end_date)}</td>
|
||||
<td>${r.reason || '—'}</td>
|
||||
<td>${App.statusBadge(r.status)}</td>
|
||||
<td>${r.department || '—'}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`}
|
||||
</div>
|
||||
</div>`;
|
||||
} 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 = `
|
||||
<div class="card" style="margin-top:1rem">
|
||||
<div class="card-header"><h3>Summary Report — ${document.getElementById('reportYear')?.value || new Date().getFullYear()}</h3></div>
|
||||
<div class="card-body">
|
||||
${data.length === 0 ? '<p class="empty-state">No records found</p>' :
|
||||
Object.entries(grouped).map(([name, monthData]) => `
|
||||
<h4 style="margin:1rem 0 0.5rem">${name}</h4>
|
||||
<table class="table table-sm"><thead><tr><th>Month</th><th>Approved</th><th>Rejected</th><th>Pending</th><th>Total</th></tr></thead><tbody>
|
||||
${Object.entries(monthData).map(([m, statuses]) => {
|
||||
const a = statuses.approved || 0;
|
||||
const r2 = statuses.rejected || 0;
|
||||
const p = statuses.pending || 0;
|
||||
return `<tr><td>${m}</td><td class="text-success">${a}</td><td class="text-danger">${r2}</td><td class="text-warning">${p}</td><td><strong>${a+r2+p}</strong></td></tr>`;
|
||||
}).join('')}
|
||||
</tbody></table>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
} catch (err) { container.innerHTML = `<div class="error-state">${err.message}</div>`; }
|
||||
},
|
||||
|
||||
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');
|
||||
}
|
||||
};
|
||||
74
schema.sql
Normal file
74
schema.sql
Normal file
@@ -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);
|
||||
24
seed.sql
Normal file
24
seed.sql
Normal file
@@ -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.
|
||||
398
server.js
Normal file
398
server.js
Normal file
@@ -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();
|
||||
174
setup.js
Normal file
174
setup.js
Normal file
@@ -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(`
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<div class="brand-logo" style="margin:0 auto 16px">${icon('shield', 24)}</div>
|
||||
<h1>CYPHER-HR</h1>
|
||||
<p>Let's set up your HR system</p>
|
||||
</div>
|
||||
<div class="setup-steps">
|
||||
<div class="step ${this.step >= 1 ? 'active' : ''} ${this.step > 1 ? 'done' : ''}">
|
||||
<div class="step-num">${this.step > 1 ? icon('check', 14) : '1'}</div>
|
||||
<span>Company</span>
|
||||
</div>
|
||||
<div class="step-line ${this.step > 1 ? 'done' : ''}"></div>
|
||||
<div class="step ${this.step >= 2 ? 'active' : ''} ${this.step > 2 ? 'done' : ''}">
|
||||
<div class="step-num">${this.step > 2 ? icon('check', 14) : '2'}</div>
|
||||
<span>Admin</span>
|
||||
</div>
|
||||
<div class="step-line ${this.step > 2 ? 'done' : ''}"></div>
|
||||
<div class="step ${this.step >= 3 ? 'active' : ''}">
|
||||
<div class="step-num">3</div>
|
||||
<span>Policies</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setup-card">${this.getStepContent()}</div>
|
||||
</div>
|
||||
`);
|
||||
},
|
||||
|
||||
getStepContent() {
|
||||
if (this.step === 1) return `
|
||||
<h2>${icon('building', 22)} Company Profile</h2>
|
||||
<form id="companyForm" onsubmit="Setup.saveCompany(event)">
|
||||
<div class="form-group">
|
||||
<label>Company Name *</label>
|
||||
<input type="text" id="companyName" required placeholder="e.g. Cypher Technologies">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Company Type *</label>
|
||||
<select id="companyIndustry" required>
|
||||
<option value="">Select company type</option>
|
||||
<option value="Technology">Technology</option>
|
||||
<option value="Healthcare">Healthcare</option>
|
||||
<option value="Finance">Finance & Banking</option>
|
||||
<option value="Education">Education</option>
|
||||
<option value="Manufacturing">Manufacturing</option>
|
||||
<option value="Retail">Retail & E-commerce</option>
|
||||
<option value="Consulting">Consulting</option>
|
||||
<option value="Real Estate">Real Estate</option>
|
||||
<option value="Media">Media & Entertainment</option>
|
||||
<option value="Non-Profit">Non-Profit</option>
|
||||
<option value="Government">Government</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Continue ${icon('chevronRight', 16)}</button>
|
||||
<div id="setupError" class="form-error" style="display:none"></div>
|
||||
</form>`;
|
||||
|
||||
if (this.step === 2) return `
|
||||
<h2>${icon('shield', 22)} Create Admin Account</h2>
|
||||
<form id="adminForm" onsubmit="Setup.saveAdmin(event)">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>First Name *</label><input type="text" id="adminFirst" required placeholder="John"></div>
|
||||
<div class="form-group"><label>Last Name *</label><input type="text" id="adminLast" required placeholder="Doe"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Email *</label><input type="email" id="adminEmail" required placeholder="admin@company.com"></div>
|
||||
<div class="form-group"><label>Password *</label><input type="password" id="adminPassword" required minlength="6" placeholder="Min 6 characters"></div>
|
||||
<button type="submit" class="btn btn-primary btn-full">Continue ${icon('chevronRight', 16)}</button>
|
||||
<div id="setupError" class="form-error" style="display:none"></div>
|
||||
</form>`;
|
||||
|
||||
if (this.step === 3) return `
|
||||
<h2>${icon('calendar', 22)} Leave Policies</h2>
|
||||
<p class="text-muted" style="margin-bottom:20px">Configure leave types and monthly limits</p>
|
||||
<form id="policyForm" onsubmit="Setup.savePolicies(event)">
|
||||
<div class="policies-grid">
|
||||
${this.defaultPolicies.map((p, i) => `
|
||||
<div class="policy-card">
|
||||
<div class="policy-header">
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="pol_en_${i}" ${p.is_enabled ? 'checked' : ''}>
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="policy-name">${p.leave_type}</span>
|
||||
</div>
|
||||
<div class="policy-body">
|
||||
<label>Monthly Limit</label>
|
||||
<input type="number" id="pol_lim_${i}" value="${p.monthly_limit}" min="0" max="30" class="input-sm">
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-full" style="margin-top:20px">Complete Setup ${icon('check', 16)}</button>
|
||||
<div id="setupError" class="form-error" style="display:none"></div>
|
||||
</form>`;
|
||||
},
|
||||
|
||||
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'; }
|
||||
}
|
||||
};
|
||||
287
styles.css
Normal file
287
styles.css
Normal file
@@ -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}}
|
||||
Reference in New Issue
Block a user