commit 16cbe65c62c2449abfbc563049cec5f2634211d4 Author: Saeful Rahman Date: Thu Aug 27 17:41:59 2026 +0700 Initialize Eigen HRIS project - Add .gitignore, .yarnrc.yml, and initial project metadata (AGENTS.md, DESIGN.md, package.json, Yarn lock, Tailwind, PostCSS, Next.js config, TypeScript config) - Add CSV data files (users, employees, contracts, attendances) - Implement API routes for auth, employees, contracts, attendances, analytics, and file upload - Add core pages (dashboard, employees, attendances, profile, login, main layout) - Add UI components (avatar, avatar picker, badge, button, card, input, modal, select, sidebar, navigation shell, top header) - Add attendance features (table, clock widget, unified permit modal with file upload, upload API) - Add employee management (contract timeline with file upload, employee form modal, employee table) - Add dashboard widgets (leaderboard, early bird, latecomer, night owl, day status feed, date filter bar) - Add utilities (avatar generator, analytics calculations, CSV DB layer, auth context) - Add type definitions for auth, employee, contract, attendance, dashboard All changes are synchronized with documentation (AGENTS.md, DESIGN.md, walkthrough). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d56cbe --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +.yarn/ +public/uploads/ +.env +.env.local +.env.development +.env.test +.env.production +.DS_Store diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..25dbb87 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# AGENTS.md - Eigen HRIS Development & Agent Blueprint + +## 1. Project Overview & System Context +**Eigen HRIS** is a modern, human-centric Human Resource Integration System designed to manage employee records, contract histories, daily attendance (WFO/WFH), unified leave & permit requests, and gamified analytical dashboards (such as top attendance streaks, early birds, chronic latecomers, and night owls). + +The application is built on **Next.js** and **TailwindCSS**, utilizing a zero-dependency **CSV-based file database** for transparent, lightweight, and robust data persistence. + +--- + +## 2. Technical Stack & Conventions + +### Core Stack +- **Package Manager:** Yarn (`yarn add`, `yarn dev`, `yarn build`) +- **Framework:** Next.js (App Router, Server Actions, and Route Handlers) +- **Styling:** TailwindCSS with bespoke design tokens, Tailwind Animate +- **Icons & UI Utilities:** Lucide React, clsx, tailwind-merge, date-fns / dayjs +- **Typography:** Plus Jakarta Sans +- **Storage Layer:** Flat `.csv` files stored in `/data/*.csv`, managed via a centralized Server-side Data Access Layer (Node.js `fs/promises` + CSV serializer/parser) with concurrency write-safety. +- **Online Photo/Avatar Generator:** Integration with dynamic online avatar endpoints (e.g. DiceBear API, Pravatar, Unsplash curated portraits, UI-Avatars) with instant preview and randomize capabilities. + +### Project Structure Blueprint +``` +├── data/ +│ ├── users.csv +│ ├── employees.csv +│ ├── employee_contracts.csv +│ └── attendances.csv +├── public/ +│ └── uploads/ +├── src/ +│ ├── app/ +│ │ ├── api/ +│ │ │ ├── auth/ +│ │ │ ├── employees/ +│ │ │ ├── contracts/ +│ │ │ ├── attendances/ +│ │ │ ├── analytics/ +│ │ │ └── upload/ +│ │ ├── (auth)/ +│ │ │ └── login/ +│ │ ├── (dashboard)/ +│ │ │ ├── dashboard/ +│ │ │ ├── employees/ +│ │ │ │ └── [id]/ +│ │ │ ├── attendances/ +│ │ │ └── profile/ +│ │ ├── layout.tsx +│ │ └── page.tsx +│ ├── components/ +│ │ ├── ui/ (Button, Input, Modal, Badge, Dropdown, Table, Card, AvatarPicker) +│ │ ├── layout/ (Sidebar, TopHeader, NavigationShell) +│ │ ├── dashboard/ (LeaderboardCard, EarlyBirdCard, LatecomerCard, NightOwlCard, DayStatusFeed) +│ │ ├── employees/ (EmployeeTable, EmployeeFormModal, ContractTimeline, OnlinePhotoPicker) +│ │ └── attendances/ (AttendanceTable, UnifiedPermitModal, ClockActionWidget, AttendanceFilterBar) +│ ├── lib/ +│ │ ├── csv-db.ts (CSV CRUD operations, locking, serialization) +│ │ ├── auth.ts (Session, Cookie handling, RBAC checks) +│ │ ├── avatar.ts (Online avatar URL generator & presets) +│ │ └── analytics.ts (Aggregation logic for streaks, early bird, latecomers, night owls) +│ ├── types/ +│ │ ├── auth.ts +│ │ ├── employee.ts +│ │ ├── contract.ts +│ │ ├── attendance.ts +│ │ └── dashboard.ts +│ └── styles/ +│ └── globals.css +├── AGENTS.md +├── DESIGN.md +└── package.json +``` + +--- + +## 3. CSV Database Schema & Models + +### `users.csv` +| Column | Type | Description | +|---|---|---| +| `id` | string (UUID) | Unique user identifier | +| `username` | string | Login username (unique) | +| `password` | string | Stored credential (hashed or plain demo password) | +| `role` | enum (`superadmin`, `staff`) | Access permission level | +| `employee_id` | string (nullable) | Associated employee ID for staff accounts | +| `created_at` | ISO string | Timestamp of user creation | + +### `employees.csv` +| Column | Type | Description | +|---|---|---| +| `id` | string (UUID/EMP-xxx) | Unique employee ID | +| `nik` | string | Employee Identification Number | +| `full_name` | string | Full legal name | +| `email` | string | Official work email address | +| `phone` | string | Contact phone number | +| `department` | string | Department/Division (e.g., Engineering, HR, Product, Finance, Marketing) | +| `position` | string | Job title / designation | +| `status` | enum (`active`, `probation`, `resigned`, `terminated`) | Current employment status | +| `join_date` | YYYY-MM-DD | Employment start date | +| `work_location_default` | enum (`WFO`, `WFH`, `HYBRID`) | Default working arrangement | +| `photo_url` | string | Generated online photo/avatar URL (Dicebear, Pravatar, Unsplash) | +| `created_at` | ISO string | Record creation timestamp | +| `updated_at` | ISO string | Last updated timestamp | + +### `employee_contracts.csv` +| Column | Type | Description | +|---|---|---| +| `id` | string (UUID) | Unique contract ID | +| `employee_id` | string | Foreign key referencing `employees.csv` | +| `contract_number` | string | Formal employment agreement reference | +| `contract_type` | enum (`PKWT`, `PKWTT`, `INTERNSHIP`, `FREELANCE`) | Contract classification | +| `start_date` | YYYY-MM-DD | Contract start date | +| `end_date` | YYYY-MM-DD (nullable) | Contract end date (`null` for permanent PKWTT) | +| `salary` | number | Base monthly compensation | +| `notes` | string | Specific provisions, job description remarks | +| `document_url` | string (nullable) | Reference to uploaded contract copy | +| `created_at` | ISO string | Creation timestamp | + +### `attendances.csv` (Unified Attendance, Leaves, & Permits Log) +| Column | Type | Description | +|---|---|---| +| `id` | string (UUID) | Attendance/Incident entry ID | +| `employee_id` | string | Foreign key referencing `employees.csv` | +| `date` | YYYY-MM-DD | Calendar date of the record | +| `type` | enum (`PRESENT`, `SICK`, `ANNUAL_LEAVE`, `PERMIT`, `LATE_PERMIT`, `EARLY_LEAVE_PERMIT`, `OFFICIAL_TRAVEL`) | Record classification | +| `work_mode` | enum (`WFO`, `WFH`, `OFF`) | Work venue mode | +| `clock_in` | HH:mm:ss (nullable) | Clock-in time | +| `clock_out` | HH:mm:ss (nullable) | Clock-out time | +| `duration_minutes` | number (nullable) | Total recorded working minutes | +| `late_minutes` | number (default: 0) | Minutes clocked in after designated work start (e.g. 09:00) | +| `status` | enum (`PENDING`, `APPROVED`, `REJECTED`, `CONFIRMED`) | Approval status for permits/leaves | +| `reason_or_notes` | string | Activity note, permit justification, or doctor note details | +| `attachment_url` | string (nullable) | Proof image/document reference | +| `created_at` | ISO string | Submission timestamp | + +--- + +## 4. Feature Specifications & Workflows + +### 4.1 Authentication & RBAC +- **Login:** Simple username & password form. +- **Roles:** + - **Superadmin:** Full access to CRUD employees, manage contract history, review and approve/reject leave/permit requests, override attendance logs, and access company-wide analytics. + - **Staff:** Limited to personal clock-in/out, personal leave/permit submissions, viewing own profile and contract history, and viewing the company leaderboard. + +### 4.2 Employee & Contract Management (with Online Photo Generator) +- **Employee Directory:** Search by name, NIK, department, and filter by status. +- **Online Photo Generator:** + - When creating or editing an employee, users can type a seed or click "Generate New Avatar" to fetch a dynamic portrait URL (e.g. `https://api.dicebear.com/7.x/lorelei/svg?seed=...`, `https://i.pravatar.cc/300?u=...`, or curated Unsplash professional portraits). + - Live preview in modal before saving. +- **Contract Timeline:** On employee detail/edit view, superadmins can view chronological contract history (e.g. Internship → PKWT 1 → PKWT 2 → PKWTT) and append new contracts. + +### 4.3 Unified Attendance & Incident Management +- **Unified Log:** A single centralized table aggregating standard attendance (clock-in / clock-out) and incidents (sakit, cuti, izin terlambat, izin pulang cepat, dinas luar). +- **Staff Actions:** + - One-click Clock In / Clock Out (with WFO/WFH toggle). + - Submit request modal for Sick Leave, Annual Leave, Late Permission, or Early Checkout with date picker, reason, and optional attachment. +- **Superadmin Actions:** + - Quick action buttons to approve or reject pending requests. + - Manual entry creation or modification. + +### 4.4 Gamified Analytics Dashboard +- **Date Range Filters:** + - Preset filters: *This Month*, *Last Month*, *Last 30 Days*, or *Custom Date Range (From - To)*. +- **Gamification Cards & Leaderboard:** + - **Top 5 "Si Paling Rajin" (Streak Champions):** Ranked by highest number of present days with zero unexcused lates in the selected date range. + - **"Si Paling Pagi" (The Early Bird):** Employee with the earliest average clock-in time. + - **"Si Paling Telat" (The Chronic Snoozer):** Employee with the highest cumulative late minutes. + - **"Si Paling Pulang Malam" (The Night Owl):** Employee with the latest average clock-out time. +- **Daily Status Feed (Filter by Today, Yesterday, Tomorrow):** + - Instant breakdown: Present (WFO/WFH count), On Leave (Cuti), Sick (Sakit), Late (Izin Telat), and Upcoming scheduled leaves for tomorrow. + +--- + +## 5. Agent Guidelines & Coding Standards +1. **Mandatory Documentation Sync (Rule #1):** Any modification, architectural change, new feature, schema alteration, or workflow update MUST be immediately recorded in both `AGENTS.md` and `DESIGN.md` alongside code changes. +2. **CSV Concurrency & Integrity:** All write operations must be atomic and handle read-modify-write cycles safely to avoid race conditions. +3. **Strict Typing:** All data models must have TypeScript definitions in `src/types/`. Validate form inputs with clean validation routines. +4. **Design Conformity:** Implement UI strictly in accordance with [DESIGN.md](./DESIGN.md) using the brand palette, Plus Jakarta Sans typography, and modern micro-interactions. +5. **Anti-AI-Slop Principle:** Avoid generic filler layouts. Use crisp typography, clear spatial hierarchy, purposeful badges, and intuitive date filters. + +--- + +## 6. Changelog & Project Evolution Log +| Date | Author / Agent | Changes & Notes | +|---|---|---| +| 2026-08-27 | System / Antigravity | Initial blueprint creation for Eigen HRIS: CSV database schemas, online photo generator (`photo_url`), gamified dashboard analytics, and mandatory documentation synchronization rule. | +| 2026-08-27 | User / Antigravity | Set **Yarn** as the designated package manager for dependency management, builds, and development workflow. | +| 2026-08-27 | System / Antigravity | Full implementation executed: Next.js App Router codebase, CSV database layer (`users.csv`, `employees.csv`, `employee_contracts.csv`, `attendances.csv`), online avatar picker (`Dicebear`, `Pravatar`, `Unsplash`, `UI-Avatars`), gamified leaderboard cards & highlights, unified attendance table, contract timeline, and role-based authentication. Built successfully with 0 errors. | +| 2026-08-27 | User / Antigravity | Implemented multipart image/document upload API (`/api/upload`) saving to public storage (`/public/uploads/`) with instant preview and download buttons across tables and permit modals. | + + + + diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..4e9c842 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,113 @@ +# DESIGN.md - Eigen HRIS Visual Design System & UI Blueprint + +## 1. Design Philosophy: "Anti-AI-Slop & Human-Centric" +Eigen HRIS avoids repetitive, low-effort template aesthetics ("AI Slop"—such as generic gray rounded cards, centered meaningless gradients, and bland stock dashboards). Instead, Eigen HRIS is engineered with: +- **High Intentionality & Structure:** Sharp borders, subtle shadows, deliberate typographic scale, and purposeful asymmetric layouts. +- **Personality-Driven Gamification:** Playful, human-centric recognition widgets for employees ("Si Paling Pagi", "Si Paling Rajin", "Si Paling Telat", "Si Paling Pulang Malam"). +- **Dynamic & Tactile Feedback:** Crisp micro-interactions, responsive hover states, smooth transitions, and distinct status indicators. + +--- + +## 2. Color Palette & Token Architecture + +The color system is derived from the approved brand palette ([Color Hunt #1b4ef53874ff5996fff4ceff](https://colorhunt.co/palette/1b4ef53874ff5996fff4ceff)): + +### Primary Brand Colors +| Token Name | Hex Code | Purpose & Application | +|---|---|---| +| `--color-brand-primary` | `#1b4ef5` | **Cobalt Electric**: Primary action buttons, active navigation markers, key metric callouts, high-emphasis icons. | +| `--color-brand-secondary` | `#3874ff` | **Royal Azure**: Secondary buttons, active interactive elements, gradient fills, table header highlights. | +| `--color-brand-tertiary` | `#5996ff` | **Sky Periwinkle**: Subtle borders, active tab underlines, hover glow, secondary badges. | +| `--color-brand-pastel` | `#f4ceff` | **Lilac Cloud / Lavender Mist**: Soft background tints for top highlight cards ("Si Paling Pagi", streak champion badges), subtle notification accents. | + +### Neutral & Semantic Colors +| Token Name | Hex Code | Purpose | +|---|---|---| +| `--color-bg-app` | `#F8FAFC` | Main application background (Clean slate-50). | +| `--color-bg-card` | `#FFFFFF` | Card surface with crisp `border border-slate-200/80`. | +| `--color-text-primary` | `#0F172A` | Primary text (Slate 900) for sharp readability. | +| `--color-text-secondary` | `#475569` | Secondary text (Slate 600) for subtitles and table contents. | +| `--color-text-muted` | `#94A3B8` | Muted labels, timestamps, and placeholder text (Slate 400). | +| `--color-status-present` | `#10B981` | Emerald Green: Present (WFO / WFH), Approved requests. | +| `--color-status-late` | `#F59E0B` | Amber: Late clock-ins, Late permits. | +| `--color-status-leave` | `#8B5CF6` | Purple: Annual leave (Cuti). | +| `--color-status-sick` | `#EC4899` | Rose / Pink: Sick leave with doctor note. | +| `--color-status-danger` | `#EF4444` | Red: Rejected requests, Contract termination. | + +--- + +## 3. Typography: Plus Jakarta Sans + +The entire interface uses **Plus Jakarta Sans** for modern geometric clarity and high legibility. + +### Hierarchy Scale +- **Display / Hero Title:** `32px` (`text-3xl`), `font-extrabold` (800), tracking `-0.03em`. +- **Page Headings:** `24px` (`text-2xl`), `font-bold` (700), tracking `-0.02em`. +- **Card / Widget Headings:** `16px` (`text-base`), `font-semibold` (600), tracking `-0.01em`. +- **Section Eyebrow / Category Label:** `12px` (`text-xs`), `font-bold` (700), `uppercase`, tracking `0.06em`, color `text-brand-primary` or `text-slate-500`. +- **Body Regular:** `14px` (`text-sm`), `font-normal` (400) / `font-medium` (500), `leading-relaxed`. +- **Tabular Data & Timestamps:** `13px` - `14px`, `font-mono` or `font-feature-settings: 'tnum'` for aligned clock times and numbers. + +--- + +## 4. UI Components & Visual Patterns + +### 4.1 Gamified Analytics Widget System +- **"Si Paling Rajin" (Streak Champion):** + - Card with electric cobalt `#1b4ef5` gradient border, flame streak badge (`🔥`), and total active days counter. +- **"Si Paling Pagi" (The Early Bird):** + - Card with soft lilac `#f4ceff` luminous background accent, golden sunburst badge (`☀️`), and average clock-in stamp (e.g., `07:42 AM`). +- **"Si Paling Telat" (The Snooze King):** + - Card with amber/warning border tint, alarm clock badge (`⏰`), and total accumulated late minutes breakdown. +- **"Si Paling Pulang Malam" (The Night Owl):** + - Card with deep blue/indigo background accent, crescent moon badge (`🌙`), and average late clock-out stamp (e.g., `20:15 PM`). + +### 4.2 Online Avatar & Photo Picker Component +- **Avatar Display:** + - Crisp rounded avatar (`rounded-2xl` or circular) with dynamic online photo URLs. + - Border indicator corresponding to employment status (Green = Active, Orange = Probation, Gray = Inactive). +- **Online Avatar Generator Modal / Picker:** + - Interactive "Generate New Avatar" button fetching dynamic SVG/PNG portraits from online services (DiceBear, Pravatar, Unsplash). + - Seed customization input allowing instant avatar regeneration based on employee name or unique strings. + +### 4.3 Navigation & Layout Shell +- **Sidebar:** + - Sleek vertical sidebar with deep cobalt active link indicators, subtle badges, and company branding (Eigen HRIS). +- **Top Header:** + - Global search bar, quick role badge (`SUPERADMIN` / `STAFF`), and user profile dropdown with quick status switch. +- **Interactive Filter Bar:** + - Segmented control pills for date ranges (*This Month*, *Last Month*, *Custom Range*). + - Day toggle buttons (*Yesterday*, *Today*, *Tomorrow*) with badge counts. + +### 4.4 Data Tables & Status Badges +- **Unified Table:** + - Clean table headers (`bg-slate-50`, sticky positioning, crisp divider). + - Row hover transition with `hover:bg-slate-50/80`. + - Action buttons with icon tooltips (Edit, View Contract, Approve/Reject). +- **Status Badges:** + - Pill design (`rounded-full px-2.5 py-1 text-xs font-semibold`) with high-contrast text and soft pastel background tints. + +--- + +## 5. Micro-Interactions & Transitions +- **Button Hover / Active:** Subtle scale transition (`active:scale-[0.98] transition-transform duration-150`). +- **Modal Dialogs:** Smooth backdrop blur (`backdrop-blur-sm bg-slate-900/40`) with fade-and-scale entrance. +- **Card Hover:** Subtle border illumination using `--color-brand-tertiary` (`#5996ff`). + +--- + +## 6. Design System Maintenance & Changelog +> [!IMPORTANT] +> **Mandatory Documentation Sync:** Any modification to design tokens, typography, component behaviors, layouts, or visual aesthetics MUST be immediately documented in this file (`DESIGN.md`) alongside implementation updates. + +### Changelog +| Date | Author / Agent | Changes & Notes | +|---|---|---| +| 2026-08-27 | System / Antigravity | Initial creation of the Eigen HRIS Design System: Color Hunt `#1b4ef53874ff5996fff4ceff` palette, Plus Jakarta Sans typography, gamified widgets, online photo picker components, and synchronization policy. | +| 2026-08-27 | User / Antigravity | Configured design system pipeline dependencies to be installed and managed via **Yarn**. | +| 2026-08-27 | System / Antigravity | Implemented complete anti-AI-slop UI components: custom tactile cards, avatar picker with live preview & style presets, status badges, celebratory confetti micro-interactions, responsive sidebars, and real-time WIB clock header. | +| 2026-08-27 | User / Antigravity | Added tactile file upload dropzone for permits/contracts, live upload preview badges, and one-click 'Lihat' (view) & 'Unduh' (download) action buttons for server-stored attachments. | + + + + diff --git a/data/attendances.csv b/data/attendances.csv new file mode 100644 index 0000000..cdc5ac6 --- /dev/null +++ b/data/attendances.csv @@ -0,0 +1,44 @@ +id,employee_id,date,type,work_mode,clock_in,clock_out,duration_minutes,late_minutes,status,reason_or_notes,attachment_url,created_at +ATT-20260801-002,EMP-002,2026-08-01,PRESENT,WFO,07:35:12,18:05:00,630,0,APPROVED,Frontend sprint planning & architecture review,,2026-08-01T07:35:12Z +ATT-20260801-003,EMP-003,2026-08-01,PRESENT,WFH,08:50:00,21:15:00,745,0,APPROVED,Database indexing & caching optimization,,2026-08-01T08:50:00Z +ATT-20260801-004,EMP-004,2026-08-01,PRESENT,WFO,07:42:00,18:30:00,648,0,APPROVED,Design system token audit,,2026-08-01T07:42:00Z +ATT-20260801-006,EMP-006,2026-08-01,PRESENT,WFO,09:45:00,18:00:00,495,45,APPROVED,Performance marketing campaign rollout,,2026-08-01T09:45:00Z +ATT-20260804-002,EMP-002,2026-08-04,PRESENT,WFO,07:30:00,18:10:00,640,0,APPROVED,Component library development,,2026-08-04T07:30:00Z +ATT-20260804-003,EMP-003,2026-08-04,PRESENT,WFH,08:55:00,21:40:00,765,0,APPROVED,Microservices deployment setup,,2026-08-04T08:55:00Z +ATT-20260804-004,EMP-004,2026-08-04,PRESENT,WFO,07:40:00,18:00:00,620,0,APPROVED,User research synthesis,,2026-08-04T07:40:00Z +ATT-20260804-006,EMP-006,2026-08-04,LATE_PERMIT,WFO,09:35:00,18:15:00,520,35,APPROVED,Ban bocor di tol dalam kota,,2026-08-04T09:35:00Z +ATT-20260805-002,EMP-002,2026-08-05,PRESENT,WFO,07:38:00,18:20:00,642,0,APPROVED,Code review for PRs,,2026-08-05T07:38:00Z +ATT-20260805-003,EMP-003,2026-08-05,PRESENT,WFH,08:45:00,20:50:00,725,0,APPROVED,API endpoints implementation,,2026-08-05T08:45:00Z +ATT-20260805-004,EMP-004,2026-08-05,PRESENT,WFO,07:45:00,18:00:00,615,0,APPROVED,Design tokens documentation,,2026-08-05T07:45:00Z +ATT-20260805-006,EMP-006,2026-08-05,PRESENT,WFO,09:50:00,18:30:00,520,50,APPROVED,Ads optimization review,,2026-08-05T09:50:00Z +ATT-20260810-002,EMP-002,2026-08-10,PRESENT,WFO,07:32:00,18:00:00,628,0,APPROVED,Refactoring state management,,2026-08-10T07:32:00Z +ATT-20260810-003,EMP-003,2026-08-10,PRESENT,WFH,08:50:00,21:30:00,760,0,APPROVED,Server performance benchmarks,,2026-08-10T08:50:00Z +ATT-20260810-004,EMP-004,2026-08-10,PRESENT,WFO,07:39:00,18:15:00,636,0,APPROVED,Prototype testing session,,2026-08-10T07:39:00Z +ATT-20260810-006,EMP-006,2026-08-10,PRESENT,WFO,09:40:00,18:00:00,500,40,APPROVED,Weekly growth metrics report,,2026-08-10T09:40:00Z +ATT-20260815-002,EMP-002,2026-08-15,PRESENT,WFO,07:34:00,18:05:00,631,0,APPROVED,Bug fixes on table components,,2026-08-15T07:34:00Z +ATT-20260815-003,EMP-003,2026-08-15,PRESENT,WFH,08:48:00,22:10:00,802,0,APPROVED,Database migration script execution,,2026-08-15T08:48:00Z +ATT-20260815-004,EMP-004,2026-08-15,PRESENT,WFO,07:36:00,18:00:00,624,0,APPROVED,Mobile viewport visual QA,,2026-08-15T07:36:00Z +ATT-20260815-006,EMP-006,2026-08-15,PRESENT,WFO,09:30:00,18:00:00,510,30,APPROVED,Campaign A/B test setup,,2026-08-15T09:30:00Z +ATT-20260820-002,EMP-002,2026-08-20,PRESENT,WFO,07:28:00,18:00:00,632,0,APPROVED,Frontend unit tests coverage,,2026-08-20T07:28:00Z +ATT-20260820-003,EMP-003,2026-08-20,PRESENT,WFH,08:52:00,21:00:00,728,0,APPROVED,WebSocket gateway integration,,2026-08-20T08:52:00Z +ATT-20260820-004,EMP-004,2026-08-20,PRESENT,WFO,07:44:00,18:10:00,626,0,APPROVED,Icon set standardization,,2026-08-20T07:44:00Z +ATT-20260820-006,EMP-006,2026-08-20,PRESENT,WFO,09:42:00,18:00:00,498,42,APPROVED,Marketing funnel audit,,2026-08-20T09:42:00Z +ATT-20260826-001,EMP-001,2026-08-26,PRESENT,WFO,08:15:00,17:30:00,555,0,APPROVED,Executive operational review,,2026-08-26T08:15:00Z +ATT-20260826-002,EMP-002,2026-08-26,PRESENT,WFO,07:31:00,18:00:00,629,0,APPROVED,Feature release deployment,,2026-08-26T07:31:00Z +ATT-20260826-003,EMP-003,2026-08-26,PRESENT,WFH,08:50:00,21:45:00,775,0,APPROVED,Load testing backend clusters,,2026-08-26T08:50:00Z +ATT-20260826-004,EMP-004,2026-08-26,PRESENT,WFO,07:40:00,18:00:00,620,0,APPROVED,Product UX teardown,,2026-08-26T07:40:00Z +ATT-20260826-005,EMP-005,2026-08-26,ANNUAL_LEAVE,OFF,,,0,0,APPROVED,Cuti tahunan urusan keluarga,,2026-08-26T08:00:00Z +ATT-20260826-006,EMP-006,2026-08-26,PRESENT,WFO,09:35:00,18:10:00,515,35,APPROVED,Content marketing strategy,,2026-08-26T09:35:00Z +ATT-20260826-007,EMP-007,2026-08-26,PRESENT,WFO,08:20:00,17:45:00,565,0,APPROVED,Budget allocation report,,2026-08-26T08:20:00Z +ATT-20260826-008,EMP-008,2026-08-26,PRESENT,WFH,08:45:00,18:00:00,555,0,APPROVED,Kubernetes cluster upgrades,,2026-08-26T08:45:00Z +ATT-20260827-001,EMP-001,2026-08-27,PRESENT,WFO,08:10:00,17:30:00,560,0,APPROVED,Weekly all-hands preparation,,2026-08-27T08:10:00Z +ATT-20260827-002,EMP-002,2026-08-27,PRESENT,WFO,07:25:00,18:00:00,635,0,APPROVED,UI performance optimization,,2026-08-27T07:25:00Z +ATT-20260827-003,EMP-003,2026-08-27,PRESENT,WFH,08:45:00,21:50:00,785,0,APPROVED,GraphQL query performance optimization,,2026-08-27T08:45:00Z +ATT-20260827-004,EMP-004,2026-08-27,PRESENT,WFO,07:38:00,18:00:00,622,0,APPROVED,Design critique & sprint planning,,2026-08-27T07:38:00Z +ATT-20260827-005,EMP-005,2026-08-27,SICK,OFF,,,0,0,APPROVED,Pemeriksaan dokter spesialis THT (surat terlampir),/uploads/sample-surat-dokter.txt,2026-08-27T08:00:00Z +ATT-20260827-006,EMP-006,2026-08-27,LATE_PERMIT,WFO,09:40:00,18:00:00,500,40,APPROVED,Macet total tol Jagorawi akibat kecelakaan,,2026-08-27T09:40:00Z +ATT-20260827-007,EMP-007,2026-08-27,ANNUAL_LEAVE,OFF,,,0,0,APPROVED,Cuti tahunan kepulangan keluarga ke Padang,,2026-08-27T08:00:00Z +ATT-20260827-008,EMP-008,2026-08-27,PRESENT,WFH,08:50:00,18:00:00,550,0,APPROVED,Infrastructure observability alerting,,2026-08-27T08:50:00Z +ATT-20260828-005,EMP-005,2026-08-28,ANNUAL_LEAVE,OFF,,,0,0,APPROVED,Istirahat pemulihan pasca tindakan medis,,2026-08-27T09:00:00Z +ATT-20260828-007,EMP-007,2026-08-28,ANNUAL_LEAVE,OFF,,,0,0,APPROVED,Cuti tahunan kepulangan keluarga,,2026-08-27T09:00:00Z +ATT-20260828-004,EMP-004,2026-08-28,OFFICIAL_TRAVEL,WFO,08:00:00,17:00:00,540,0,APPROVED,Dinas Luar: Client Product Pitching di SCBD,,2026-08-27T10:00:00Z diff --git a/data/employee_contracts.csv b/data/employee_contracts.csv new file mode 100644 index 0000000..cca4a59 --- /dev/null +++ b/data/employee_contracts.csv @@ -0,0 +1,12 @@ +id,employee_id,contract_number,contract_type,start_date,end_date,salary,notes,document_url,created_at +CTR-001,EMP-001,001/EIG-HR/PKWTT/I/2024,PKWTT,2024-01-01,,32000000,Permanent appointment as VP of People & Ops,https://example.com/docs/contract-001.pdf,2024-01-01T08:00:00Z +CTR-002,EMP-002,014/EIG-HR/PKWT-1/III/2024,PKWT,2024-03-15,2025-03-14,20000000,Initial 1-year contract as Lead Frontend Engineer,https://example.com/docs/contract-002.pdf,2024-03-15T08:00:00Z +CTR-003,EMP-002,088/EIG-HR/PKWTT/III/2025,PKWTT,2025-03-15,,24000000,Promoted to permanent employee with salary adjustment,https://example.com/docs/contract-003.pdf,2025-03-15T08:00:00Z +CTR-004,EMP-003,022/EIG-HR/PKWT-1/V/2024,PKWT,2024-05-01,2025-04-30,22000000,Senior Backend Architect initial contract,https://example.com/docs/contract-004.pdf,2024-05-01T08:00:00Z +CTR-005,EMP-003,099/EIG-HR/PKWTT/V/2025,PKWTT,2025-05-01,,26000000,Permanent Senior Backend Architect,https://example.com/docs/contract-005.pdf,2025-05-01T08:00:00Z +CTR-006,EMP-004,031/EIG-HR/PKWTT/VI/2024,PKWTT,2024-06-10,,23000000,Permanent Principal Product Designer,https://example.com/docs/contract-006.pdf,2024-06-10T08:00:00Z +CTR-007,EMP-005,045/EIG-HR/PKWT-1/VIII/2024,PKWT,2024-08-01,2025-07-31,14000000,People Engagement Specialist contract,https://example.com/docs/contract-007.pdf,2024-08-01T08:00:00Z +CTR-008,EMP-005,105/EIG-HR/PKWT-2/VIII/2025,PKWT,2025-08-01,2026-07-31,16500000,Contract extension PKWT 2,https://example.com/docs/contract-008.pdf,2025-08-01T08:00:00Z +CTR-009,EMP-006,052/EIG-HR/PKWT-1/II/2025,PKWT,2025-02-01,2026-01-31,15000000,Growth Marketing Lead 1-year contract,https://example.com/docs/contract-009.pdf,2025-02-01T08:00:00Z +CTR-010,EMP-007,063/EIG-HR/PKWTT/IV/2025,PKWTT,2025-04-15,,18000000,Permanent Financial Analyst,https://example.com/docs/contract-010.pdf,2025-04-15T08:00:00Z +CTR-011,EMP-008,077/EIG-HR/PKWT-1/VII/2025,PKWT,2025-07-01,2026-06-30,17500000,DevOps Engineer 1-year term,https://example.com/docs/contract-011.pdf,2025-07-01T08:00:00Z diff --git a/data/employees.csv b/data/employees.csv new file mode 100644 index 0000000..393903f --- /dev/null +++ b/data/employees.csv @@ -0,0 +1,10 @@ +id,nik,full_name,email,phone,department,position,status,join_date,work_location_default,photo_url,created_at,updated_at +EMP-001,EIG-2024-001,Raden Bagus Arya,arya.superadmin@eigen.io,+628112345678,Executive,VP of People & Operations,active,2024-01-01,WFO,https://api.dicebear.com/7.x/avataaars/svg?seed=Raden%20Bagus%20Arya-Rocket-655&backgroundColor=ebf2ff,Mon Jan 01 2024 15:00:00 GMT+0700 (Western Indonesia Time),2026-08-27T10:29:10.589Z +EMP-002,EIG-2024-014,Budi Santoso,budi.santoso@eigen.io,+628123456789,Engineering,Lead Frontend Engineer,active,2024-03-15,HYBRID,https://api.dicebear.com/7.x/notionists/svg?seed=BudiSantoso&backgroundColor=f4ceff,Fri Mar 15 2024 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-003,EIG-2024-022,Siti Aminah,siti.aminah@eigen.io,+628139876543,Engineering,Senior Backend Architect,active,2024-05-01,WFH,https://api.dicebear.com/7.x/lorelei/svg?seed=SitiAminah&backgroundColor=ebf2ff,Wed May 01 2024 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-004,EIG-2024-031,Reza Rahadian,reza.rahadian@eigen.io,+628145678901,Product,Principal Product Designer,active,2024-06-10,WFO,https://api.dicebear.com/7.x/notionists/svg?seed=RezaDesign&backgroundColor=f4ceff,Mon Jun 10 2024 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-005,EIG-2024-045,Dian Sastrowardoyo,dian.sastro@eigen.io,+628156789012,Human Resources,People Engagement Specialist,active,2024-08-01,HYBRID,https://api.dicebear.com/7.x/lorelei/svg?seed=DianSastro&backgroundColor=ebf2ff,Thu Aug 01 2024 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-006,EIG-2025-052,Andika Pratama,andika.pratama@eigen.io,+628167890123,Marketing,Growth Marketing Lead,probation,2025-02-01,WFO,https://api.dicebear.com/7.x/notionists/svg?seed=AndikaGrowth&backgroundColor=f4ceff,Sat Feb 01 2025 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-007,EIG-2025-063,Nadia Hutagalung,nadia.hutagalung@eigen.io,+628178901234,Finance,Senior Financial Analyst,active,2025-04-15,WFO,https://api.dicebear.com/7.x/lorelei/svg?seed=NadiaFinance&backgroundColor=ebf2ff,Tue Apr 15 2025 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-008,EIG-2025-077,Farhan Maulana,farhan.m@eigen.io,+628189012345,Engineering,DevOps & Cloud Engineer,active,2025-07-01,WFH,https://api.dicebear.com/7.x/notionists/svg?seed=FarhanDevOps&backgroundColor=f4ceff,Tue Jul 01 2025 15:00:00 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 15:00:00 GMT+0700 (Western Indonesia Time) +EMP-009,EIG-2026-941,Jamal,jamal@eigen.co.id,+628454545454,Engineering,Senior Backend Engineer,active,2026-08-27,WFO,https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=300&h=300&fit=crop&crop=faces,Thu Aug 27 2026 17:26:52 GMT+0700 (Western Indonesia Time),Thu Aug 27 2026 17:26:52 GMT+0700 (Western Indonesia Time) \ No newline at end of file diff --git a/data/users.csv b/data/users.csv new file mode 100644 index 0000000..eab552f --- /dev/null +++ b/data/users.csv @@ -0,0 +1,7 @@ +id,username,password,role,employee_id,created_at +USR-001,superadmin,admin123,superadmin,EMP-001,2026-01-01T08:00:00Z +USR-002,budi.santoso,staff123,staff,EMP-002,2026-01-10T08:00:00Z +USR-003,siti.aminah,staff123,staff,EMP-003,2026-01-15T08:00:00Z +USR-004,reza.rahadian,staff123,staff,EMP-004,2026-02-01T08:00:00Z +USR-005,dian.sastro,staff123,staff,EMP-005,2026-02-15T08:00:00Z +USR-006,andika.pratama,staff123,staff,EMP-006,2026-03-01T08:00:00Z diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 0000000..ff5dbcf --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,25 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'api.dicebear.com', + }, + { + protocol: 'https', + hostname: 'i.pravatar.cc', + }, + { + protocol: 'https', + hostname: 'images.unsplash.com', + }, + { + protocol: 'https', + hostname: 'ui-avatars.com', + }, + ], + }, +}; + +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..d5ad1f8 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "eigen-hris", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "canvas-confetti": "^1.9.3", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.400.0", + "next": "^14.2.5", + "papaparse": "^5.4.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.3.0" + }, + "devDependencies": { + "@types/canvas-confetti": "^1.9.0", + "@types/node": "^20.14.9", + "@types/papaparse": "^5.3.14", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.3" + }, + "packageManager": "yarn@4.14.1" +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..2ef30fc --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts new file mode 100644 index 0000000..56137a8 --- /dev/null +++ b/src/app/api/analytics/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from 'next/server'; +import { getEmployees, getAttendances } from '@/lib/csv-db'; +import { computeDashboardAnalytics } from '@/lib/analytics'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const preset = searchParams.get('preset') || 'this_month'; + let from = searchParams.get('from'); + let to = searchParams.get('to'); + + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth(); + + if (!from || !to) { + if (preset === 'this_month') { + const firstDay = new Date(currentYear, currentMonth, 1); + const lastDay = new Date(currentYear, currentMonth + 1, 0); + from = firstDay.toISOString().slice(0, 10); + to = lastDay.toISOString().slice(0, 10); + } else if (preset === 'last_month') { + const firstDay = new Date(currentYear, currentMonth - 1, 1); + const lastDay = new Date(currentYear, currentMonth, 0); + from = firstDay.toISOString().slice(0, 10); + to = lastDay.toISOString().slice(0, 10); + } else if (preset === 'last_30_days') { + const past = new Date(now); + past.setDate(past.getDate() - 30); + from = past.toISOString().slice(0, 10); + to = now.toISOString().slice(0, 10); + } else { + from = '2024-01-01'; + to = '2026-12-31'; + } + } + + const employees = await getEmployees(); + const attendances = await getAttendances(); + + const analytics = computeDashboardAnalytics(employees, attendances, { + from, + to, + preset, + }); + + return NextResponse.json({ analytics }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/attendances/route.ts b/src/app/api/attendances/route.ts new file mode 100644 index 0000000..04faf5d --- /dev/null +++ b/src/app/api/attendances/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from 'next/server'; +import { getAttendances, getEmployees, createAttendance, updateAttendance, deleteAttendance } from '@/lib/csv-db'; +import { AttendanceWithEmployee } from '@/types/attendance'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const employeeId = searchParams.get('employee_id'); + const date = searchParams.get('date'); + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const type = searchParams.get('type'); + const status = searchParams.get('status'); + + let attendances = await getAttendances(); + const employees = await getEmployees(); + const empMap = new Map(employees.map((e) => [e.id, e])); + + if (employeeId) { + attendances = attendances.filter((a) => a.employee_id === employeeId); + } + if (date) { + attendances = attendances.filter((a) => a.date === date); + } + if (from && to) { + attendances = attendances.filter((a) => a.date >= from && a.date <= to); + } + if (type) { + attendances = attendances.filter((a) => a.type === type); + } + if (status) { + attendances = attendances.filter((a) => a.status === status); + } + + // Sort descending by date, then created_at + attendances.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + const result: AttendanceWithEmployee[] = attendances.map((a) => ({ + ...a, + employee: empMap.get(a.employee_id), + })); + + return NextResponse.json({ attendances: result }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + if (!body.employee_id || !body.date || !body.type) { + return NextResponse.json({ error: 'Missing required attendance fields' }, { status: 400 }); + } + + // Check if duplicate entry already exists for this employee on this date + const allAttendances = await getAttendances(); + const existing = allAttendances.find( + (a) => a.employee_id === body.employee_id && a.date === body.date && a.type === body.type + ); + + if (existing && body.type === 'PRESENT') { + return NextResponse.json( + { error: 'Record already exists for this employee today. Please update or clock out.' }, + { status: 400 } + ); + } + + const newAttendance = await createAttendance(body); + return NextResponse.json({ attendance: newAttendance }, { status: 201 }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function PUT(request: Request) { + try { + const body = await request.json(); + const { id, ...updateData } = body; + if (!id) { + return NextResponse.json({ error: 'Attendance ID is required' }, { status: 400 }); + } + + const updated = await updateAttendance(id, updateData); + if (!updated) { + return NextResponse.json({ error: 'Attendance record not found' }, { status: 404 }); + } + + return NextResponse.json({ attendance: updated }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Attendance ID required' }, { status: 400 }); + } + + const success = await deleteAttendance(id); + if (!success) { + return NextResponse.json({ error: 'Record not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..d9883c3 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; +import { getUserByUsername, getEmployeeById } from '@/lib/csv-db'; + +export async function POST(request: Request) { + try { + const { username, password } = await request.json(); + if (!username || !password) { + return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }); + } + + const user = await getUserByUsername(username); + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 401 }); + } + + if (user.password && user.password !== password) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }); + } + + let employee = null; + if (user.employee_id) { + employee = await getEmployeeById(user.employee_id); + } + + const sessionData = { + user: { + id: user.id, + username: user.username, + role: user.role, + employee_id: user.employee_id, + created_at: user.created_at, + }, + employee, + }; + + const response = NextResponse.json({ success: true, session: sessionData }); + response.cookies.set('eigen_session', JSON.stringify(sessionData), { + httpOnly: false, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return response; + } catch (error: any) { + return NextResponse.json({ error: error.message || 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..e05e5c4 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const response = NextResponse.json({ success: true }); + response.cookies.delete('eigen_session'); + return response; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..a3d105f --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { getUserById, getEmployeeById } from '@/lib/csv-db'; + +export async function GET() { + try { + const cookieStore = cookies(); + const sessionCookie = cookieStore.get('eigen_session'); + + if (!sessionCookie?.value) { + // Default demo superadmin session if no cookie yet + const adminUser = await getUserById('USR-001'); + const adminEmployee = adminUser?.employee_id ? await getEmployeeById(adminUser.employee_id) : null; + return NextResponse.json({ + authenticated: true, + session: { + user: adminUser, + employee: adminEmployee, + }, + }); + } + + const sessionData = JSON.parse(sessionCookie.value); + return NextResponse.json({ + authenticated: true, + session: sessionData, + }); + } catch (error: any) { + return NextResponse.json({ authenticated: false, session: null }, { status: 200 }); + } +} diff --git a/src/app/api/contracts/route.ts b/src/app/api/contracts/route.ts new file mode 100644 index 0000000..0a7abee --- /dev/null +++ b/src/app/api/contracts/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { getContracts, createContract, deleteContract } from '@/lib/csv-db'; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const employeeId = searchParams.get('employee_id'); + + const contracts = await getContracts(); + if (employeeId) { + return NextResponse.json({ + contracts: contracts.filter((c) => c.employee_id === employeeId), + }); + } + + return NextResponse.json({ contracts }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + if (!body.employee_id || !body.contract_number || !body.contract_type || !body.start_date) { + return NextResponse.json({ error: 'Missing required contract fields' }, { status: 400 }); + } + + const newContract = await createContract(body); + return NextResponse.json({ contract: newContract }, { status: 201 }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get('id'); + if (!id) { + return NextResponse.json({ error: 'Contract ID required' }, { status: 400 }); + } + + const success = await deleteContract(id); + if (!success) { + return NextResponse.json({ error: 'Contract not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/employees/[id]/route.ts b/src/app/api/employees/[id]/route.ts new file mode 100644 index 0000000..fe555f2 --- /dev/null +++ b/src/app/api/employees/[id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server'; +import { getEmployeeById, updateEmployee, deleteEmployee, getContractsByEmployeeId, getAttendancesByEmployeeId } from '@/lib/csv-db'; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + try { + const employee = await getEmployeeById(params.id); + if (!employee) { + return NextResponse.json({ error: 'Employee not found' }, { status: 404 }); + } + + const contracts = await getContractsByEmployeeId(params.id); + const attendances = await getAttendancesByEmployeeId(params.id); + + return NextResponse.json({ + employee, + contracts, + attendances, + }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + try { + const body = await request.json(); + const updated = await updateEmployee(params.id, body); + if (!updated) { + return NextResponse.json({ error: 'Employee not found' }, { status: 404 }); + } + return NextResponse.json({ employee: updated }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + try { + const success = await deleteEmployee(params.id); + if (!success) { + return NextResponse.json({ error: 'Employee not found' }, { status: 404 }); + } + return NextResponse.json({ success: true }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/employees/route.ts b/src/app/api/employees/route.ts new file mode 100644 index 0000000..51e2223 --- /dev/null +++ b/src/app/api/employees/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server'; +import { getEmployees, createEmployee } from '@/lib/csv-db'; + +export async function GET() { + try { + const employees = await getEmployees(); + return NextResponse.json({ employees }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const body = await request.json(); + if (!body.full_name || !body.email || !body.department) { + return NextResponse.json({ error: 'Missing required employee fields' }, { status: 400 }); + } + + const newEmployee = await createEmployee(body); + return NextResponse.json({ employee: newEmployee }, { status: 201 }); + } catch (error: any) { + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..64a6dda --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import fs from 'fs/promises'; +import path from 'path'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +export async function POST(request: Request) { + try { + const formData = await request.formData(); + const file = formData.get('file') as File | null; + + if (!file) { + return NextResponse.json({ error: 'No file uploaded' }, { status: 400 }); + } + + const bytes = await file.arrayBuffer(); + const buffer = Buffer.from(bytes); + + // Create /public/uploads directory if not exists + const uploadsDir = path.join(process.cwd(), 'public', 'uploads'); + await fs.mkdir(uploadsDir, { recursive: true }); + + // Clean original name and generate unique filename + const timestamp = Date.now(); + const ext = path.extname(file.name) || '.png'; + const cleanBase = path + .basename(file.name, ext) + .replace(/[^a-zA-Z0-9_-]/g, '_') + .slice(0, 30); + const filename = `${cleanBase}-${timestamp}${ext}`; + const filePath = path.join(uploadsDir, filename); + + await fs.writeFile(filePath, buffer); + + const publicUrl = `/uploads/${filename}`; + + return NextResponse.json({ + success: true, + url: publicUrl, + filename, + size: file.size, + type: file.type, + }); + } catch (error: any) { + console.error('File upload error:', error); + return NextResponse.json({ error: error.message || 'Failed to upload file' }, { status: 500 }); + } +} diff --git a/src/app/attendances/page.tsx b/src/app/attendances/page.tsx new file mode 100644 index 0000000..a79bf11 --- /dev/null +++ b/src/app/attendances/page.tsx @@ -0,0 +1,138 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { NavigationShell } from '@/components/layout/NavigationShell'; +import { TopHeader } from '@/components/layout/TopHeader'; +import { AttendanceTable } from '@/components/attendances/AttendanceTable'; +import { ClockActionWidget } from '@/components/attendances/ClockActionWidget'; +import { UnifiedPermitModal } from '@/components/attendances/UnifiedPermitModal'; +import { useAuth } from '@/context/AuthContext'; +import { AttendanceWithEmployee, AttendanceStatus, AttendanceInput, Attendance } from '@/types/attendance'; +import { Employee } from '@/types/employee'; +import { Button } from '@/components/ui/Button'; +import { PlusCircle, Loader2 } from 'lucide-react'; + +export default function AttendancesPage() { + const { user, employee } = useAuth(); + const [attendances, setAttendances] = useState([]); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [isPermitModalOpen, setIsPermitModalOpen] = useState(false); + const [todayAttendance, setTodayAttendance] = useState(null); + + const isSuperadmin = user?.role === 'superadmin'; + + const fetchData = useCallback(async () => { + try { + setLoading(true); + const [attRes, empRes] = await Promise.all([ + fetch('/api/attendances'), + fetch('/api/employees'), + ]); + + const attData = await attRes.json(); + const empData = await empRes.json(); + + if (attData?.attendances) { + setAttendances(attData.attendances); + + if (employee) { + const todayStr = new Date().toISOString().slice(0, 10); + const found = attData.attendances.find( + (a: Attendance) => a.employee_id === employee.id && a.date === todayStr && a.type === 'PRESENT' + ); + setTodayAttendance(found || null); + } + } + + if (empData?.employees) { + setEmployees(empData.employees); + } + } catch (e) { + console.error('Failed to fetch attendances:', e); + } finally { + setLoading(false); + } + }, [employee]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleUpdateStatus = async (id: string, status: AttendanceStatus) => { + await fetch('/api/attendances', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, status }), + }); + await fetchData(); + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Hapus record absensi ini?')) return; + await fetch(`/api/attendances?id=${id}`, { method: 'DELETE' }); + await fetchData(); + }; + + const handleCreatePermit = async (data: AttendanceInput) => { + await fetch('/api/attendances', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + await fetchData(); + }; + + return ( + + setIsPermitModalOpen(true)} + className="font-bold shadow-sm" + > + Ajukan Izin / Cuti + + } + /> + +
+ {/* Staff Clock In/Out Widget */} + {user?.role === 'staff' && ( + + )} + + {/* Attendance Log Table */} + {loading ? ( +
+ + Memuat rekapan absensi... +
+ ) : ( + + )} +
+ + setIsPermitModalOpen(false)} + employees={employees} + currentEmployee={employee} + isSuperadmin={isSuperadmin} + onSubmit={handleCreatePermit} + /> +
+ ); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..566af20 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -0,0 +1,182 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { NavigationShell } from '@/components/layout/NavigationShell'; +import { TopHeader } from '@/components/layout/TopHeader'; +import { DateFilterBar } from '@/components/dashboard/DateFilterBar'; +import { LeaderboardCard } from '@/components/dashboard/LeaderboardCard'; +import { EarlyBirdCard } from '@/components/dashboard/EarlyBirdCard'; +import { LatecomerCard } from '@/components/dashboard/LatecomerCard'; +import { NightOwlCard } from '@/components/dashboard/NightOwlCard'; +import { DayStatusFeed } from '@/components/dashboard/DayStatusFeed'; +import { ClockActionWidget } from '@/components/attendances/ClockActionWidget'; +import { UnifiedPermitModal } from '@/components/attendances/UnifiedPermitModal'; +import { useAuth } from '@/context/AuthContext'; +import { DashboardAnalytics } from '@/types/dashboard'; +import { Attendance, AttendanceInput } from '@/types/attendance'; +import { Employee } from '@/types/employee'; +import { Button } from '@/components/ui/Button'; +import { PlusCircle, Loader2, Sparkles } from 'lucide-react'; + +export default function DashboardPage() { + const { user, employee } = useAuth(); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + const [currentPreset, setCurrentPreset] = useState('this_month'); + const [fromVal, setFromVal] = useState(''); + const [toVal, setToVal] = useState(''); + const [isPermitModalOpen, setIsPermitModalOpen] = useState(false); + const [employeesList, setEmployeesList] = useState([]); + const [todayAttendance, setTodayAttendance] = useState(null); + + const fetchAnalytics = useCallback(async (preset = currentPreset, from = fromVal, to = toVal) => { + try { + setLoading(true); + let url = `/api/analytics?preset=${preset}`; + if (preset === 'custom' && from && to) { + url = `/api/analytics?preset=custom&from=${from}&to=${to}`; + } + const res = await fetch(url); + const data = await res.json(); + if (data?.analytics) { + setAnalytics(data.analytics); + setFromVal(data.analytics.dateRange.from); + setToVal(data.analytics.dateRange.to); + } + } catch (e) { + console.error('Failed to load analytics:', e); + } finally { + setLoading(false); + } + }, [currentPreset, fromVal, toVal]); + + const fetchAuxData = useCallback(async () => { + try { + const [empRes, attRes] = await Promise.all([ + fetch('/api/employees'), + fetch(`/api/attendances?date=${new Date().toISOString().slice(0, 10)}`), + ]); + const empData = await empRes.json(); + const attData = await attRes.json(); + + if (empData?.employees) { + setEmployeesList(empData.employees); + } + + if (attData?.attendances && employee) { + const found = attData.attendances.find( + (a: Attendance) => a.employee_id === employee.id && a.type === 'PRESENT' + ); + setTodayAttendance(found || null); + } + } catch (e) { + console.error('Aux data error:', e); + } + }, [employee]); + + useEffect(() => { + fetchAnalytics(); + fetchAuxData(); + }, [fetchAnalytics, fetchAuxData]); + + const handleFilterChange = (preset: string, from?: string, to?: string) => { + setCurrentPreset(preset); + if (from && to) { + setFromVal(from); + setToVal(to); + fetchAnalytics(preset, from, to); + } else { + fetchAnalytics(preset); + } + }; + + const handleCreatePermit = async (data: AttendanceInput) => { + const res = await fetch('/api/attendances', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.ok) { + fetchAnalytics(); + fetchAuxData(); + } + }; + + return ( + + setIsPermitModalOpen(true)} + className="font-bold shadow-sm" + > + Ajukan Izin / Cuti + + } + /> + +
+ {/* Staff Quick Clock Widget (if logged in as staff) */} + {user?.role === 'staff' && ( + { + fetchAnalytics(); + fetchAuxData(); + }} + /> + )} + + {/* Filter Bar */} + + + {loading && !analytics ? ( +
+ + Mengalkulasi metrik gamifikasi... +
+ ) : analytics ? ( + <> + {/* Gamification Highlights Grid */} +
+ + + +
+ + {/* Leaderboard + Summary Row */} +
+ +
+ + {/* Daily Status Breakdown Feed */} + + + ) : null} +
+ + {/* Unified Permit Modal */} + setIsPermitModalOpen(false)} + employees={employeesList} + currentEmployee={employee} + isSuperadmin={user?.role === 'superadmin'} + onSubmit={handleCreatePermit} + /> +
+ ); +} diff --git a/src/app/employees/[id]/page.tsx b/src/app/employees/[id]/page.tsx new file mode 100644 index 0000000..105654f --- /dev/null +++ b/src/app/employees/[id]/page.tsx @@ -0,0 +1,273 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { NavigationShell } from '@/components/layout/NavigationShell'; +import { TopHeader } from '@/components/layout/TopHeader'; +import { ContractTimeline } from '@/components/employees/ContractTimeline'; +import { EmployeeFormModal } from '@/components/employees/EmployeeFormModal'; +import { Avatar } from '@/components/ui/Avatar'; +import { Badge, BadgeVariant } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { useAuth } from '@/context/AuthContext'; +import { Employee, EmployeeInput } from '@/types/employee'; +import { EmployeeContract, ContractInput } from '@/types/contract'; +import { Attendance } from '@/types/attendance'; +import { + ArrowLeft, + Mail, + Phone, + Building2, + Calendar, + MapPin, + Briefcase, + Edit2, + Loader2, + Clock, + CheckCircle2, +} from 'lucide-react'; + +export default function EmployeeDetailPage() { + const params = useParams(); + const router = useRouter(); + const { user } = useAuth(); + const employeeId = params.id as string; + + const [employee, setEmployee] = useState(null); + const [contracts, setContracts] = useState([]); + const [attendances, setAttendances] = useState([]); + const [loading, setLoading] = useState(true); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const isSuperadmin = user?.role === 'superadmin'; + + const fetchDetail = useCallback(async () => { + try { + setLoading(true); + const res = await fetch(`/api/employees/${employeeId}`); + const data = await res.json(); + if (data?.employee) { + setEmployee(data.employee); + setContracts(data.contracts || []); + setAttendances(data.attendances || []); + } + } catch (e) { + console.error('Failed to fetch employee detail:', e); + } finally { + setLoading(false); + } + }, [employeeId]); + + useEffect(() => { + if (employeeId) { + fetchDetail(); + } + }, [employeeId, fetchDetail]); + + const handleUpdateEmployee = async (data: EmployeeInput) => { + await fetch(`/api/employees/${employeeId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + await fetchDetail(); + }; + + const handleAddContract = async (data: ContractInput) => { + await fetch('/api/contracts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + await fetchDetail(); + }; + + const handleDeleteContract = async (contractId: string) => { + if (!window.confirm('Hapus riwayat kontrak ini?')) return; + await fetch(`/api/contracts?id=${contractId}`, { method: 'DELETE' }); + await fetchDetail(); + }; + + if (loading) { + return ( + +
+ + Memuat profil karyawan... +
+
+ ); + } + + if (!employee) { + return ( + +
+

Karyawan Tidak Ditemukan

+ +
+
+ ); + } + + return ( + + + + {isSuperadmin && ( + + )} + + } + /> + +
+ {/* Profile Card */} + +
+
+ +
+
+

+ {employee.full_name} +

+ + {employee.status} + +
+

+ {employee.position} +

+

+ NIK: {employee.nik} • ID: {employee.id} +

+
+
+ +
+
+ Default Work Mode + + {employee.work_location_default} + +
+ +
+ Tanggal Bergabung + + {employee.join_date} + +
+
+
+ + {/* Details Grid */} +
+
+ +
+ Email Kantor + {employee.email} +
+
+ +
+ +
+ Nomor Telepon + {employee.phone || '-'} +
+
+ +
+ +
+ Divisi / Departemen + {employee.department} +
+
+
+
+ + {/* Contract History Timeline Section */} + + + {/* Personal Attendance Records */} + +
+
+

+ Riwayat Absensi & Izin ({attendances.length} Log) +

+

+ Log kehadiran harian dan pengajuan izin karyawan +

+
+
+ +
+ {attendances.length === 0 ? ( +
+ Belum ada catatan absensi untuk karyawan ini. +
+ ) : ( + attendances.slice(0, 10).map((att) => ( +
+
+ {att.date} + + {att.type} + + {att.reason_or_notes || '-'} +
+ +
+ {att.clock_in ? `${att.clock_in} - ${att.clock_out || 'Aktif'}` : '-'} +
+
+ )) + )} +
+
+
+ + setIsEditModalOpen(false)} + employeeToEdit={employee} + onSave={handleUpdateEmployee} + /> +
+ ); +} diff --git a/src/app/employees/page.tsx b/src/app/employees/page.tsx new file mode 100644 index 0000000..7364f93 --- /dev/null +++ b/src/app/employees/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { NavigationShell } from '@/components/layout/NavigationShell'; +import { TopHeader } from '@/components/layout/TopHeader'; +import { EmployeeTable } from '@/components/employees/EmployeeTable'; +import { EmployeeFormModal } from '@/components/employees/EmployeeFormModal'; +import { useAuth } from '@/context/AuthContext'; +import { Employee, EmployeeInput } from '@/types/employee'; +import { Button } from '@/components/ui/Button'; +import { UserPlus, Loader2 } from 'lucide-react'; + +export default function EmployeesPage() { + const { user } = useAuth(); + const [employees, setEmployees] = useState([]); + const [loading, setLoading] = useState(true); + const [isModalOpen, setIsModalOpen] = useState(false); + const [employeeToEdit, setEmployeeToEdit] = useState(null); + + const isSuperadmin = user?.role === 'superadmin'; + + const fetchEmployees = async () => { + try { + setLoading(true); + const res = await fetch('/api/employees'); + const data = await res.json(); + if (data?.employees) { + setEmployees(data.employees); + } + } catch (e) { + console.error('Failed to load employees:', e); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEmployees(); + }, []); + + const handleOpenCreate = () => { + setEmployeeToEdit(null); + setIsModalOpen(true); + }; + + const handleOpenEdit = (emp: Employee) => { + setEmployeeToEdit(emp); + setIsModalOpen(true); + }; + + const handleSave = async (data: EmployeeInput) => { + if (employeeToEdit) { + // Update + await fetch(`/api/employees/${employeeToEdit.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } else { + // Create + await fetch('/api/employees', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + } + await fetchEmployees(); + }; + + const handleDelete = async (id: string) => { + if (!window.confirm('Apakah Anda yakin ingin menghapus data karyawan ini?')) return; + await fetch(`/api/employees/${id}`, { method: 'DELETE' }); + await fetchEmployees(); + }; + + return ( + + + Tambah Karyawan + + ) : undefined + } + /> + +
+ {loading ? ( +
+ + Memuat direktori karyawan... +
+ ) : ( + + )} +
+ + setIsModalOpen(false)} + employeeToEdit={employeeToEdit} + onSave={handleSave} + /> +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..365dca3 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,40 @@ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --font-jakarta: 'Plus Jakarta Sans', sans-serif; + --color-brand-primary: #1b4ef5; + --color-brand-secondary: #3874ff; + --color-brand-tertiary: #5996ff; + --color-brand-pastel: #f4ceff; +} + +body { + font-family: var(--font-jakarta); + background-color: #f8fafc; + color: #0f172a; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Custom crisp scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 9999px; +} + +::-webkit-scrollbar-thumb:hover { + background: #94a3b8; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..1fc793a --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import './globals.css'; +import { AuthProvider } from '@/context/AuthContext'; + +export const metadata: Metadata = { + title: 'Eigen HRIS - Human Resource Integration System', + description: 'Modern People Ops, Gamified Attendance Analytics, and Contract Management', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + {children} + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..1daed67 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,162 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/context/AuthContext'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Card } from '@/components/ui/Card'; +import { Shield, Sparkles, User, Lock, ArrowRight, CheckCircle2 } from 'lucide-react'; + +export default function LoginPage() { + const router = useRouter(); + const { login } = useAuth(); + const [username, setUsername] = useState('superadmin'); + const [password, setPassword] = useState('admin123'); + const [error, setError] = useState(''); + const [isLoading, setIsLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setIsLoading(true); + + try { + const success = await login(username, password); + if (success) { + router.push('/dashboard'); + } else { + setError('Username atau password tidak valid.'); + } + } catch { + setError('Terjadi kesalahan jaringan.'); + } finally { + setIsLoading(false); + } + }; + + const setDemoAccount = (u: string, p: string) => { + setUsername(u); + setPassword(p); + }; + + return ( +
+ {/* Background visual blobs */} +
+
+ +
+ {/* Brand Icon */} +
+
+ E +
+
+ +

+ EigenHRIS Portal +

+

+ Human Resource Integration & Gamified Attendance Analytics +

+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + setUsername(e.target.value)} + placeholder="Masukkan username..." + icon={} + /> + + setPassword(e.target.value)} + placeholder="••••••••" + icon={} + /> + + + + + {/* Demo fast accounts */} +
+
+ + Akun Demo Cepat: + +
+ +
+ + + + + + + +
+
+
+
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..aa1bda2 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function HomePage() { + redirect('/dashboard'); +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000..7eeb329 --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,203 @@ +'use client'; + +import React, { useState, useEffect, useCallback } from 'react'; +import { NavigationShell } from '@/components/layout/NavigationShell'; +import { TopHeader } from '@/components/layout/TopHeader'; +import { ContractTimeline } from '@/components/employees/ContractTimeline'; +import { ClockActionWidget } from '@/components/attendances/ClockActionWidget'; +import { AvatarPickerModal } from '@/components/ui/AvatarPickerModal'; +import { Avatar } from '@/components/ui/Avatar'; +import { Badge, BadgeVariant } from '@/components/ui/Badge'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { useAuth } from '@/context/AuthContext'; +import { EmployeeContract } from '@/types/contract'; +import { Attendance } from '@/types/attendance'; +import { + Mail, + Phone, + Building2, + Calendar, + MapPin, + Sparkles, + Loader2, + CheckCircle2, +} from 'lucide-react'; + +export default function ProfilePage() { + const { user, employee, refreshSession } = useAuth(); + const [contracts, setContracts] = useState([]); + const [attendances, setAttendances] = useState([]); + const [todayAttendance, setTodayAttendance] = useState(null); + const [loading, setLoading] = useState(true); + const [isAvatarPickerOpen, setIsAvatarPickerOpen] = useState(false); + + const fetchProfileData = useCallback(async () => { + if (!employee) { + setLoading(false); + return; + } + + try { + setLoading(true); + const [contractRes, attRes] = await Promise.all([ + fetch(`/api/contracts?employee_id=${employee.id}`), + fetch(`/api/attendances?employee_id=${employee.id}`), + ]); + + const cData = await contractRes.json(); + const aData = await attRes.json(); + + if (cData?.contracts) setContracts(cData.contracts); + if (aData?.attendances) { + setAttendances(aData.attendances); + const todayStr = new Date().toISOString().slice(0, 10); + const found = aData.attendances.find( + (a: Attendance) => a.date === todayStr && a.type === 'PRESENT' + ); + setTodayAttendance(found || null); + } + } catch (e) { + console.error('Profile data error:', e); + } finally { + setLoading(false); + } + }, [employee]); + + useEffect(() => { + fetchProfileData(); + }, [fetchProfileData]); + + const handleUpdateAvatar = async (newUrl: string) => { + if (!employee) return; + await fetch(`/api/employees/${employee.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ photo_url: newUrl }), + }); + await refreshSession(); + await fetchProfileData(); + }; + + return ( + + + +
+ {/* Profile Banner */} + +
+
+
+ + +
+ +
+
+

+ {employee?.full_name || user?.username} +

+ + {employee?.status || 'Active'} + +
+

+ {employee?.position || 'Superadmin Operator'} +

+

+ NIK: {employee?.nik || 'SYS-ADMIN'} • Role: {user?.role.toUpperCase()} +

+
+
+ + +
+ +
+
+ +
+ Email Resmi + {employee?.email || 'admin@eigen.io'} +
+
+ +
+ +
+ Nomor WhatsApp + {employee?.phone || '-'} +
+
+ +
+ +
+ Departemen + {employee?.department || 'Operations'} +
+
+
+
+ + {/* Staff Clock widget */} + {user?.role === 'staff' && ( + + )} + + {/* Contract History */} + {employee && ( + { + await fetch('/api/contracts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + await fetchProfileData(); + }} + onDeleteContract={async (id) => { + await fetch(`/api/contracts?id=${id}`, { method: 'DELETE' }); + await fetchProfileData(); + }} + /> + )} +
+ + setIsAvatarPickerOpen(false)} + employeeName={employee?.full_name || 'Staff'} + currentUrl={employee?.photo_url} + onSelect={handleUpdateAvatar} + /> +
+ ); +} diff --git a/src/components/attendances/AttendanceTable.tsx b/src/components/attendances/AttendanceTable.tsx new file mode 100644 index 0000000..ad9e8f7 --- /dev/null +++ b/src/components/attendances/AttendanceTable.tsx @@ -0,0 +1,337 @@ +'use client'; + +import React, { useState } from 'react'; +import { AttendanceWithEmployee, AttendanceType, AttendanceStatus } from '@/types/attendance'; +import { Avatar } from '@/components/ui/Avatar'; +import { Badge, BadgeVariant } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/Button'; +import { + Search, + CheckCircle, + XCircle, + Trash2, + ExternalLink, + Building2, + Home, + Clock, + FileHeart, + CalendarDays, + Plane, + AlertCircle, + Calendar, + Download, + Eye, +} from 'lucide-react'; + +interface AttendanceTableProps { + attendances: AttendanceWithEmployee[]; + isSuperadmin: boolean; + onUpdateStatus: (id: string, status: AttendanceStatus) => Promise; + onDelete: (id: string) => Promise; +} + +export const AttendanceTable: React.FC = ({ + attendances, + isSuperadmin, + onUpdateStatus, + onDelete, +}) => { + const [searchTerm, setSearchTerm] = useState(''); + const [typeFilter, setTypeFilter] = useState('ALL'); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [dateFilter, setDateFilter] = useState(''); + + const filtered = attendances.filter((item) => { + const empName = item.employee?.full_name || ''; + const empNik = item.employee?.nik || ''; + const notes = item.reason_or_notes || ''; + + const matchesSearch = + empName.toLowerCase().includes(searchTerm.toLowerCase()) || + empNik.toLowerCase().includes(searchTerm.toLowerCase()) || + notes.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesType = typeFilter === 'ALL' || item.type === typeFilter; + const matchesStatus = statusFilter === 'ALL' || item.status === statusFilter; + const matchesDate = !dateFilter || item.date === dateFilter; + + return matchesSearch && matchesType && matchesStatus && matchesDate; + }); + + const getTypeBadgeVariant = (type: AttendanceType): BadgeVariant => { + switch (type) { + case 'PRESENT': + return 'present'; + case 'SICK': + return 'sick'; + case 'ANNUAL_LEAVE': + return 'leave'; + case 'LATE_PERMIT': + return 'late'; + case 'OFFICIAL_TRAVEL': + return 'travel'; + default: + return 'neutral'; + } + }; + + const getTypeIcon = (type: AttendanceType) => { + switch (type) { + case 'PRESENT': + return ; + case 'SICK': + return ; + case 'ANNUAL_LEAVE': + return ; + case 'LATE_PERMIT': + return ; + case 'OFFICIAL_TRAVEL': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Filters Bar */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 text-sm bg-slate-50 border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary" + /> +
+ +
+ {/* Specific Date Filter */} +
+ + setDateFilter(e.target.value)} + className="bg-transparent text-xs font-semibold text-slate-700 focus:outline-none" + /> + {dateFilter && ( + + )} +
+ + {/* Type Filter */} + + + {/* Status Filter */} + +
+
+ + {/* Unified Table */} +
+
+ + + + + + + + + + {isSuperadmin && } + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((item) => ( + + {/* Date */} + + + {/* Employee */} + + + {/* Type & Mode */} + + + {/* Timestamps */} + + + {/* Reason & Attachment */} + + + {/* Status */} + + + {/* HR Superadmin Actions */} + {isSuperadmin && ( + + )} + + )) + )} + +
TanggalKaryawanKlasifikasi LogClock In / OutKeterangan / LampiranStatusAksi HR
+ Tidak ada log absensi atau izin yang sesuai dengan filter. +
+ + {item.date} + + +
+ +
+

+ {item.employee?.full_name || 'Unknown'} +

+

+ {item.employee?.department} +

+
+
+
+
+ {getTypeIcon(item.type)} + + {item.type.replace(/_/g, ' ')} + +
+ {item.work_mode !== 'OFF' && ( +
+ + {item.work_mode === 'WFO' ? ( + <> Office (WFO) + ) : ( + <> Remote (WFH) + )} + +
+ )} +
+ {item.clock_in ? ( +
+

+ In: {item.clock_in} + {item.late_minutes > 0 && ( + + (+{item.late_minutes}m) + + )} +

+

+ Out: {item.clock_out || (item.status === 'APPROVED' ? 'Aktif' : '-')} +

+
+ ) : ( + - + )} +
+

+ {item.reason_or_notes || '-'} +

+ {item.attachment_url && ( + + )} +
+ + {item.status} + + +
+ {item.status === 'PENDING' && ( + <> + + + + )} + +
+
+
+
+
+ ); +}; diff --git a/src/components/attendances/ClockActionWidget.tsx b/src/components/attendances/ClockActionWidget.tsx new file mode 100644 index 0000000..69cf0aa --- /dev/null +++ b/src/components/attendances/ClockActionWidget.tsx @@ -0,0 +1,286 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useAuth } from '@/context/AuthContext'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/Badge'; +import confetti from 'canvas-confetti'; +import { + LogIn, + LogOut, + Building2, + Home, + CheckCircle2, + Clock, + Sparkles, +} from 'lucide-react'; +import { WorkMode, Attendance } from '@/types/attendance'; + +interface ClockActionWidgetProps { + todayAttendance: Attendance | null; + onRefresh: () => void; +} + +export const ClockActionWidget: React.FC = ({ + todayAttendance, + onRefresh, +}) => { + const { user, employee } = useAuth(); + const [workMode, setWorkMode] = useState('WFO'); + const [notes, setNotes] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [timerString, setTimerString] = useState('00:00:00'); + + const isClockedIn = Boolean(todayAttendance?.clock_in); + const isClockedOut = Boolean(todayAttendance?.clock_out); + + // Live work duration counter + useEffect(() => { + if (!isClockedIn || isClockedOut || !todayAttendance?.clock_in) { + return; + } + + const interval = setInterval(() => { + const parts = todayAttendance.clock_in!.split(':').map(Number); + const startTime = new Date(); + startTime.setHours(parts[0], parts[1], parts[2] || 0, 0); + + const now = new Date(); + const diffMs = Math.max(0, now.getTime() - startTime.getTime()); + const totalSec = Math.floor(diffMs / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + + setTimerString( + `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + ); + }, 1000); + + return () => clearInterval(interval); + }, [isClockedIn, isClockedOut, todayAttendance]); + + const handleClockIn = async () => { + if (!employee) return; + setIsSubmitting(true); + + try { + const now = new Date(); + const timeStr = now.toTimeString().slice(0, 8); + const dateStr = now.toISOString().slice(0, 10); + + // Late calculation after 09:00:00 + let lateMin = 0; + const hour = now.getHours(); + const min = now.getMinutes(); + if (hour > 9 || (hour === 9 && min > 0)) { + lateMin = (hour - 9) * 60 + min; + } + + const payload = { + employee_id: employee.id, + date: dateStr, + type: 'PRESENT', + work_mode: workMode, + clock_in: timeStr, + clock_out: null, + duration_minutes: null, + late_minutes: lateMin, + status: 'APPROVED', + reason_or_notes: notes || `Hadir bertugas ${workMode}`, + }; + + const res = await fetch('/api/attendances', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (res.ok) { + confetti({ + particleCount: 60, + spread: 60, + origin: { y: 0.7 }, + }); + onRefresh(); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleClockOut = async () => { + if (!todayAttendance) return; + setIsSubmitting(true); + + try { + const now = new Date(); + const timeStr = now.toTimeString().slice(0, 8); + + // Duration calculation + let durationMin = 0; + if (todayAttendance.clock_in) { + const inParts = todayAttendance.clock_in.split(':').map(Number); + const inMinutes = inParts[0] * 60 + inParts[1]; + const outMinutes = now.getHours() * 60 + now.getMinutes(); + durationMin = Math.max(0, outMinutes - inMinutes); + } + + const res = await fetch('/api/attendances', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: todayAttendance.id, + clock_out: timeStr, + duration_minutes: durationMin, + }), + }); + + if (res.ok) { + onRefresh(); + } + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {/* Glow decorative */} +
+ +
+
+
+ + Staff Clock Widget + +

+ Presensi Kehadiran Harian +

+

+ {employee?.full_name} • {employee?.position || 'Staff Account'} +

+
+ + {/* Status Pills */} +
+ {isClockedIn ? ( + + Sedang Aktif Bekerja + + ) : ( + + Belum Clock-In + + )} +
+
+ + {/* Action Controls */} +
+ {/* Work Mode Selector */} + {!isClockedIn && ( +
+ +
+ + +
+
+ )} + + {/* Clock In info / live counter */} + {isClockedIn && ( +
+ + Waktu Masuk & Durasi + +
+ + {todayAttendance?.clock_in} + + + {isClockedOut ? `Selesai (${todayAttendance?.duration_minutes}m)` : `Durasi: ${timerString}`} + +
+
+ )} + + {/* Activity note input */} + {!isClockedIn && ( +
+ + setNotes(e.target.value)} + placeholder="Target harian / sprint task..." + className="w-full px-3.5 py-2 text-xs bg-white/10 text-white placeholder:text-white/50 border border-white/20 rounded-xl focus:outline-none focus:ring-2 focus:ring-white/40" + /> +
+ )} + + {/* Primary Action Button */} +
+ {!isClockedIn ? ( + + ) : !isClockedOut ? ( + + ) : ( +
+ + Clock-out pukul: {todayAttendance?.clock_out} + + + Total Bekerja: {todayAttendance?.duration_minutes ?? 0} Menit + +
+ )} +
+
+
+ + ); +}; diff --git a/src/components/attendances/UnifiedPermitModal.tsx b/src/components/attendances/UnifiedPermitModal.tsx new file mode 100644 index 0000000..cc593fb --- /dev/null +++ b/src/components/attendances/UnifiedPermitModal.tsx @@ -0,0 +1,284 @@ +'use client'; + +import React, { useState, useRef } from 'react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { AttendanceInput, AttendanceType } from '@/types/attendance'; +import { Employee } from '@/types/employee'; +import { + FileText, + Calendar, + UploadCloud, + Image as ImageIcon, + CheckCircle2, + Trash2, + ExternalLink, + Loader2, +} from 'lucide-react'; + +interface UnifiedPermitModalProps { + isOpen: boolean; + onClose: () => void; + employees: Employee[]; + currentEmployee: Employee | null; + isSuperadmin: boolean; + onSubmit: (data: AttendanceInput) => Promise; +} + +export const UnifiedPermitModal: React.FC = ({ + isOpen, + onClose, + employees, + currentEmployee, + isSuperadmin, + onSubmit, +}) => { + const [employeeId, setEmployeeId] = useState(currentEmployee?.id || employees[0]?.id || ''); + const [type, setType] = useState('ANNUAL_LEAVE'); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [lateMinutes, setLateMinutes] = useState(30); + const [reason, setReason] = useState(''); + const [attachmentUrl, setAttachmentUrl] = useState(''); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef(null); + + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + setUploadError(''); + + try { + const formData = new FormData(); + formData.append('file', file); + + const res = await fetch('/api/upload', { + method: 'POST', + body: formData, + }); + + const data = await res.json(); + if (res.ok && data.url) { + setAttachmentUrl(data.url); + } else { + setUploadError(data.error || 'Gagal mengunggah file.'); + } + } catch (err: any) { + setUploadError('Terjadi kesalahan saat mengunggah file.'); + } finally { + setIsUploading(false); + } + }; + + const handleRemoveAttachment = () => { + setAttachmentUrl(''); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + + try { + const payload: AttendanceInput = { + employee_id: isSuperadmin ? employeeId : (currentEmployee?.id || employeeId), + date, + type, + work_mode: type === 'OFFICIAL_TRAVEL' ? 'WFO' : 'OFF', + clock_in: type === 'LATE_PERMIT' ? '09:30:00' : null, + clock_out: null, + duration_minutes: null, + late_minutes: type === 'LATE_PERMIT' ? lateMinutes : 0, + status: isSuperadmin ? 'APPROVED' : 'PENDING', + reason_or_notes: reason, + attachment_url: attachmentUrl || null, + }; + + await onSubmit(payload); + onClose(); + setReason(''); + setAttachmentUrl(''); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+ {/* Superadmin Employee Selector */} + {isSuperadmin ? ( + setType(e.target.value as AttendanceType)} + options={[ + { value: 'ANNUAL_LEAVE', label: 'Cuti Tahunan (Annual Leave)' }, + { value: 'SICK', label: 'Izin Sakit (Surat Dokter / Resep)' }, + { value: 'LATE_PERMIT', label: 'Izin Terlambat Masuk' }, + { value: 'EARLY_LEAVE_PERMIT', label: 'Izin Pulang Lebih Awal' }, + { value: 'OFFICIAL_TRAVEL', label: 'Dinas Luar / Perjalanan Dinas' }, + { value: 'PERMIT', label: 'Izin Keperluan Khusus / Mendesak' }, + ]} + /> + + setDate(e.target.value)} + icon={} + /> +
+ + {type === 'LATE_PERMIT' && ( + setLateMinutes(Number(e.target.value))} + placeholder="30" + /> + )} + +
+ +