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).
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
.yarn/
|
||||||
|
public/uploads/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.test
|
||||||
|
.env.production
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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. |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -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)
|
||||||
|
@@ -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
|
||||||
|
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AttendanceWithEmployee[]>([]);
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isPermitModalOpen, setIsPermitModalOpen] = useState(false);
|
||||||
|
const [todayAttendance, setTodayAttendance] = useState<Attendance | null>(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 (
|
||||||
|
<NavigationShell>
|
||||||
|
<TopHeader
|
||||||
|
title="Unified Absensi & Berita Acara"
|
||||||
|
subtitle="Pusat pencatatan terpadu absensi (WFO/WFH), izin sakit, cuti, telat, dan dinas luar"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setIsPermitModalOpen(true)}
|
||||||
|
className="font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" /> Ajukan Izin / Cuti
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 max-w-7xl mx-auto w-full space-y-6">
|
||||||
|
{/* Staff Clock In/Out Widget */}
|
||||||
|
{user?.role === 'staff' && (
|
||||||
|
<ClockActionWidget
|
||||||
|
todayAttendance={todayAttendance}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attendance Log Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold">Memuat rekapan absensi...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<AttendanceTable
|
||||||
|
attendances={attendances}
|
||||||
|
isSuperadmin={isSuperadmin}
|
||||||
|
onUpdateStatus={handleUpdateStatus}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UnifiedPermitModal
|
||||||
|
isOpen={isPermitModalOpen}
|
||||||
|
onClose={() => setIsPermitModalOpen(false)}
|
||||||
|
employees={employees}
|
||||||
|
currentEmployee={employee}
|
||||||
|
isSuperadmin={isSuperadmin}
|
||||||
|
onSubmit={handleCreatePermit}
|
||||||
|
/>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<DashboardAnalytics | null>(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<Employee[]>([]);
|
||||||
|
const [todayAttendance, setTodayAttendance] = useState<Attendance | null>(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 (
|
||||||
|
<NavigationShell>
|
||||||
|
<TopHeader
|
||||||
|
title="Dashboard & Gamifikasi"
|
||||||
|
subtitle="Visualisasi kehadiran cerdas, leaderboard 'Si Paling', dan feed status real-time"
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="pastel"
|
||||||
|
onClick={() => setIsPermitModalOpen(true)}
|
||||||
|
className="font-bold shadow-sm"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" /> Ajukan Izin / Cuti
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 space-y-6 max-w-7xl mx-auto w-full">
|
||||||
|
{/* Staff Quick Clock Widget (if logged in as staff) */}
|
||||||
|
{user?.role === 'staff' && (
|
||||||
|
<ClockActionWidget
|
||||||
|
todayAttendance={todayAttendance}
|
||||||
|
onRefresh={() => {
|
||||||
|
fetchAnalytics();
|
||||||
|
fetchAuxData();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Bar */}
|
||||||
|
<DateFilterBar
|
||||||
|
currentPreset={currentPreset}
|
||||||
|
currentFrom={fromVal}
|
||||||
|
currentTo={toVal}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading && !analytics ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold">Mengalkulasi metrik gamifikasi...</span>
|
||||||
|
</div>
|
||||||
|
) : analytics ? (
|
||||||
|
<>
|
||||||
|
{/* Gamification Highlights Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
<EarlyBirdCard data={analytics.earlyBird} />
|
||||||
|
<LatecomerCard data={analytics.latecomer} />
|
||||||
|
<NightOwlCard data={analytics.nightOwl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leaderboard + Summary Row */}
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
<LeaderboardCard leaderboard={analytics.leaderboard} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Status Breakdown Feed */}
|
||||||
|
<DayStatusFeed
|
||||||
|
todayStatus={analytics.todayStatus}
|
||||||
|
yesterdayStatus={analytics.yesterdayStatus}
|
||||||
|
tomorrowStatus={analytics.tomorrowStatus}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unified Permit Modal */}
|
||||||
|
<UnifiedPermitModal
|
||||||
|
isOpen={isPermitModalOpen}
|
||||||
|
onClose={() => setIsPermitModalOpen(false)}
|
||||||
|
employees={employeesList}
|
||||||
|
currentEmployee={employee}
|
||||||
|
isSuperadmin={user?.role === 'superadmin'}
|
||||||
|
onSubmit={handleCreatePermit}
|
||||||
|
/>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Employee | null>(null);
|
||||||
|
const [contracts, setContracts] = useState<EmployeeContract[]>([]);
|
||||||
|
const [attendances, setAttendances] = useState<Attendance[]>([]);
|
||||||
|
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 (
|
||||||
|
<NavigationShell>
|
||||||
|
<div className="py-24 flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold">Memuat profil karyawan...</span>
|
||||||
|
</div>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
return (
|
||||||
|
<NavigationShell>
|
||||||
|
<div className="p-8 max-w-lg mx-auto text-center space-y-4">
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">Karyawan Tidak Ditemukan</h3>
|
||||||
|
<Button variant="outline" onClick={() => router.push('/employees')}>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Kembali ke Direktori
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavigationShell>
|
||||||
|
<TopHeader
|
||||||
|
title={employee.full_name}
|
||||||
|
subtitle={`${employee.position} • ${employee.department}`}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => router.push('/employees')}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" /> Kembali
|
||||||
|
</Button>
|
||||||
|
{isSuperadmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setIsEditModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" /> Edit Profil & Avatar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 max-w-7xl mx-auto w-full space-y-6">
|
||||||
|
{/* Profile Card */}
|
||||||
|
<Card className="p-6 sm:p-7 bg-white border border-slate-200/90 shadow-sm rounded-3xl">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Avatar
|
||||||
|
src={employee.photo_url}
|
||||||
|
name={employee.full_name}
|
||||||
|
size="2xl"
|
||||||
|
status={employee.status}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-extrabold text-slate-900 tracking-tight">
|
||||||
|
{employee.full_name}
|
||||||
|
</h2>
|
||||||
|
<Badge variant={employee.status as BadgeVariant} size="sm">
|
||||||
|
{employee.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-brand-primary">
|
||||||
|
{employee.position}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono text-slate-400">
|
||||||
|
NIK: {employee.nik} • ID: {employee.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-50 border border-slate-200/80 text-xs space-y-0.5">
|
||||||
|
<span className="text-slate-400 block font-medium">Default Work Mode</span>
|
||||||
|
<strong className="text-slate-800 font-bold flex items-center gap-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-brand-primary" /> {employee.work_location_default}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-2xl bg-slate-50 border border-slate-200/80 text-xs space-y-0.5">
|
||||||
|
<span className="text-slate-400 block font-medium">Tanggal Bergabung</span>
|
||||||
|
<strong className="text-slate-800 font-bold flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-brand-primary" /> {employee.join_date}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-5 text-xs">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Email Kantor</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Nomor Telepon</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee.phone || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Divisi / Departemen</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee.department}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Contract History Timeline Section */}
|
||||||
|
<ContractTimeline
|
||||||
|
employeeId={employee.id}
|
||||||
|
contracts={contracts}
|
||||||
|
isSuperadmin={isSuperadmin}
|
||||||
|
onAddContract={handleAddContract}
|
||||||
|
onDeleteContract={handleDeleteContract}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Personal Attendance Records */}
|
||||||
|
<Card className="bg-white p-6 rounded-2xl border border-slate-200/90 shadow-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-extrabold text-slate-900 text-base">
|
||||||
|
Riwayat Absensi & Izin ({attendances.length} Log)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Log kehadiran harian dan pengajuan izin karyawan
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 text-xs">
|
||||||
|
{attendances.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-slate-400">
|
||||||
|
Belum ada catatan absensi untuk karyawan ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
attendances.slice(0, 10).map((att) => (
|
||||||
|
<div key={att.id} className="py-2.5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="font-mono font-bold text-slate-800">{att.date}</span>
|
||||||
|
<Badge variant={att.type === 'PRESENT' ? 'present' : 'leave'} size="sm">
|
||||||
|
{att.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-slate-500 line-clamp-1">{att.reason_or_notes || '-'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right font-mono text-slate-700">
|
||||||
|
{att.clock_in ? `${att.clock_in} - ${att.clock_out || 'Aktif'}` : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmployeeFormModal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onClose={() => setIsEditModalOpen(false)}
|
||||||
|
employeeToEdit={employee}
|
||||||
|
onSave={handleUpdateEmployee}
|
||||||
|
/>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Employee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [employeeToEdit, setEmployeeToEdit] = useState<Employee | null>(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 (
|
||||||
|
<NavigationShell>
|
||||||
|
<TopHeader
|
||||||
|
title="Direktori & Manajemen Karyawan"
|
||||||
|
subtitle="Kelola master data karyawan, penempatan, dan foto avatar online"
|
||||||
|
action={
|
||||||
|
isSuperadmin ? (
|
||||||
|
<Button size="sm" variant="primary" onClick={handleOpenCreate} className="font-bold shadow-sm">
|
||||||
|
<UserPlus className="w-4 h-4" /> Tambah Karyawan
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 max-w-7xl mx-auto w-full space-y-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-brand-primary" />
|
||||||
|
<span className="text-sm font-semibold">Memuat direktori karyawan...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmployeeTable
|
||||||
|
employees={employees}
|
||||||
|
isSuperadmin={isSuperadmin}
|
||||||
|
onEdit={handleOpenEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmployeeFormModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
employeeToEdit={employeeToEdit}
|
||||||
|
onSave={handleSave}
|
||||||
|
/>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="antialiased font-sans bg-slate-50 min-h-screen">
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 flex flex-col justify-center py-12 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||||
|
{/* Background visual blobs */}
|
||||||
|
<div className="absolute top-0 left-1/4 w-96 h-96 bg-brand-pastel/30 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-brand-primary/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="sm:mx-auto sm:w-full sm:max-w-md relative z-10">
|
||||||
|
{/* Brand Icon */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-14 h-14 rounded-3xl bg-gradient-to-tr from-brand-primary via-brand-secondary to-brand-tertiary flex items-center justify-center text-white font-black text-2xl shadow-brand-glow">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-center text-2xl sm:text-3xl font-extrabold text-slate-900 tracking-tight">
|
||||||
|
Eigen<span className="text-brand-primary">HRIS</span> Portal
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-center text-xs sm:text-sm text-slate-500 font-medium">
|
||||||
|
Human Resource Integration & Gamified Attendance Analytics
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md relative z-10">
|
||||||
|
<Card className="p-7 sm:p-8 bg-white border border-slate-200/90 shadow-xl rounded-3xl">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 rounded-xl bg-red-50 border border-red-200 text-xs font-semibold text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Username Akun"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="Masukkan username..."
|
||||||
|
icon={<User className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
icon={<Lock className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
className="w-full font-bold shadow-md shadow-brand-glow mt-2"
|
||||||
|
isLoading={isLoading}
|
||||||
|
>
|
||||||
|
Masuk ke Sistem <ArrowRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Demo fast accounts */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-slate-100 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-brand-primary" /> Akun Demo Cepat:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDemoAccount('superadmin', 'admin123')}
|
||||||
|
className="p-2.5 rounded-xl text-left border border-slate-200 hover:border-brand-primary hover:bg-brand-light/40 transition-all text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-brand-primary flex items-center gap-1">
|
||||||
|
<Shield className="w-3 h-3" /> Superadmin
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">superadmin / admin123</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDemoAccount('budi.santoso', 'staff123')}
|
||||||
|
className="p-2.5 rounded-xl text-left border border-slate-200 hover:border-brand-primary hover:bg-brand-light/40 transition-all text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-slate-800 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-500" /> Budi (Rajin/Pagi)
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">budi.santoso / staff123</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDemoAccount('siti.aminah', 'staff123')}
|
||||||
|
className="p-2.5 rounded-xl text-left border border-slate-200 hover:border-brand-primary hover:bg-brand-light/40 transition-all text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-slate-800 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3 text-indigo-500" /> Siti (Malam/WFH)
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">siti.aminah / staff123</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDemoAccount('andika.pratama', 'staff123')}
|
||||||
|
className="p-2.5 rounded-xl text-left border border-slate-200 hover:border-brand-primary hover:bg-brand-light/40 transition-all text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-bold text-slate-800 flex items-center gap-1">
|
||||||
|
<Sparkles className="w-3 h-3 text-amber-500" /> Andika (Late Log)
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-slate-400 font-mono">andika.pratama / staff123</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
@@ -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<EmployeeContract[]>([]);
|
||||||
|
const [attendances, setAttendances] = useState<Attendance[]>([]);
|
||||||
|
const [todayAttendance, setTodayAttendance] = useState<Attendance | null>(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 (
|
||||||
|
<NavigationShell>
|
||||||
|
<TopHeader
|
||||||
|
title="Profil & Riwayat Pribadi"
|
||||||
|
subtitle="Kelola profil akun, foto avatar online, dan lihat history kontrak kerja"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 max-w-7xl mx-auto w-full space-y-6">
|
||||||
|
{/* Profile Banner */}
|
||||||
|
<Card className="p-6 sm:p-7 bg-white border border-slate-200/90 shadow-sm rounded-3xl">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="relative group">
|
||||||
|
<Avatar
|
||||||
|
src={employee?.photo_url}
|
||||||
|
name={employee?.full_name || user?.username || 'User'}
|
||||||
|
size="2xl"
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAvatarPickerOpen(true)}
|
||||||
|
className="absolute inset-0 rounded-2xl bg-black/40 text-white opacity-0 group-hover:opacity-100 flex flex-col items-center justify-center text-[10px] font-bold transition-opacity backdrop-blur-xs"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4 mb-0.5" /> Ganti Avatar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-extrabold text-slate-900 tracking-tight">
|
||||||
|
{employee?.full_name || user?.username}
|
||||||
|
</h2>
|
||||||
|
<Badge variant={(employee?.status || 'active') as BadgeVariant} size="sm">
|
||||||
|
{employee?.status || 'Active'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-brand-primary">
|
||||||
|
{employee?.position || 'Superadmin Operator'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono text-slate-400">
|
||||||
|
NIK: {employee?.nik || 'SYS-ADMIN'} • Role: {user?.role.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="pastel"
|
||||||
|
onClick={() => setIsAvatarPickerOpen(true)}
|
||||||
|
className="font-bold self-start md:self-auto"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" /> Kustom Online Avatar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-5 text-xs">
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Email Resmi</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee?.email || 'admin@eigen.io'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Nomor WhatsApp</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee?.phone || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-xl bg-slate-50">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-400" />
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block">Departemen</span>
|
||||||
|
<span className="font-semibold text-slate-800">{employee?.department || 'Operations'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Staff Clock widget */}
|
||||||
|
{user?.role === 'staff' && (
|
||||||
|
<ClockActionWidget
|
||||||
|
todayAttendance={todayAttendance}
|
||||||
|
onRefresh={fetchProfileData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contract History */}
|
||||||
|
{employee && (
|
||||||
|
<ContractTimeline
|
||||||
|
employeeId={employee.id}
|
||||||
|
contracts={contracts}
|
||||||
|
isSuperadmin={user?.role === 'superadmin'}
|
||||||
|
onAddContract={async (data) => {
|
||||||
|
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();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AvatarPickerModal
|
||||||
|
isOpen={isAvatarPickerOpen}
|
||||||
|
onClose={() => setIsAvatarPickerOpen(false)}
|
||||||
|
employeeName={employee?.full_name || 'Staff'}
|
||||||
|
currentUrl={employee?.photo_url}
|
||||||
|
onSelect={handleUpdateAvatar}
|
||||||
|
/>
|
||||||
|
</NavigationShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
||||||
|
onDelete: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AttendanceTable: React.FC<AttendanceTableProps> = ({
|
||||||
|
attendances,
|
||||||
|
isSuperadmin,
|
||||||
|
onUpdateStatus,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||||
|
const [dateFilter, setDateFilter] = useState<string>('');
|
||||||
|
|
||||||
|
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 <CheckCircle className="w-3.5 h-3.5 text-emerald-600" />;
|
||||||
|
case 'SICK':
|
||||||
|
return <FileHeart className="w-3.5 h-3.5 text-rose-600" />;
|
||||||
|
case 'ANNUAL_LEAVE':
|
||||||
|
return <CalendarDays className="w-3.5 h-3.5 text-purple-600" />;
|
||||||
|
case 'LATE_PERMIT':
|
||||||
|
return <Clock className="w-3.5 h-3.5 text-amber-600" />;
|
||||||
|
case 'OFFICIAL_TRAVEL':
|
||||||
|
return <Plane className="w-3.5 h-3.5 text-cyan-600" />;
|
||||||
|
default:
|
||||||
|
return <AlertCircle className="w-3.5 h-3.5 text-slate-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters Bar */}
|
||||||
|
<div className="p-4 bg-white rounded-2xl border border-slate-200/90 shadow-sm flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari karyawan, NIK, atau catatan berita acara..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Specific Date Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 bg-slate-50 border border-slate-200 px-3 py-1.5 rounded-xl text-xs">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFilter}
|
||||||
|
onChange={(e) => setDateFilter(e.target.value)}
|
||||||
|
className="bg-transparent text-xs font-semibold text-slate-700 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{dateFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setDateFilter('')}
|
||||||
|
className="text-slate-400 hover:text-slate-700 ml-1 font-bold"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type Filter */}
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs font-semibold bg-slate-50 border border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="ALL">Semua Jenis Log</option>
|
||||||
|
<option value="PRESENT">Hadir (Present)</option>
|
||||||
|
<option value="ANNUAL_LEAVE">Cuti Tahunan</option>
|
||||||
|
<option value="SICK">Izin Sakit</option>
|
||||||
|
<option value="LATE_PERMIT">Izin Terlambat</option>
|
||||||
|
<option value="OFFICIAL_TRAVEL">Dinas Luar</option>
|
||||||
|
<option value="PERMIT">Izin Khusus</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs font-semibold bg-slate-50 border border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="ALL">Semua Status</option>
|
||||||
|
<option value="APPROVED">Approved</option>
|
||||||
|
<option value="PENDING">Pending Approval</option>
|
||||||
|
<option value="REJECTED">Rejected</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unified Table */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
<th className="py-3.5 px-4">Tanggal</th>
|
||||||
|
<th className="py-3.5 px-4">Karyawan</th>
|
||||||
|
<th className="py-3.5 px-4">Klasifikasi Log</th>
|
||||||
|
<th className="py-3.5 px-4">Clock In / Out</th>
|
||||||
|
<th className="py-3.5 px-4">Keterangan / Lampiran</th>
|
||||||
|
<th className="py-3.5 px-4">Status</th>
|
||||||
|
{isSuperadmin && <th className="py-3.5 px-4 text-right">Aksi HR</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-sm">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={isSuperadmin ? 7 : 6} className="py-12 text-center text-slate-400">
|
||||||
|
Tidak ada log absensi atau izin yang sesuai dengan filter.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-slate-50/60 transition-colors">
|
||||||
|
{/* Date */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className="font-mono text-xs font-bold text-slate-800">
|
||||||
|
{item.date}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Employee */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar
|
||||||
|
src={item.employee?.photo_url}
|
||||||
|
name={item.employee?.full_name || 'Staff'}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-slate-900 text-xs">
|
||||||
|
{item.employee?.full_name || 'Unknown'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono">
|
||||||
|
{item.employee?.department}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Type & Mode */}
|
||||||
|
<td className="py-3.5 px-4 space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{getTypeIcon(item.type)}
|
||||||
|
<Badge variant={getTypeBadgeVariant(item.type)} size="sm">
|
||||||
|
{item.type.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{item.work_mode !== 'OFF' && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider flex items-center gap-1">
|
||||||
|
{item.work_mode === 'WFO' ? (
|
||||||
|
<><Building2 className="w-3 h-3 text-blue-500" /> Office (WFO)</>
|
||||||
|
) : (
|
||||||
|
<><Home className="w-3 h-3 text-indigo-500" /> Remote (WFH)</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
{item.clock_in ? (
|
||||||
|
<div className="space-y-0.5 text-xs font-mono">
|
||||||
|
<p className="text-slate-800 font-bold">
|
||||||
|
In: {item.clock_in}
|
||||||
|
{item.late_minutes > 0 && (
|
||||||
|
<span className="text-amber-600 font-semibold ml-1.5">
|
||||||
|
(+{item.late_minutes}m)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-500">
|
||||||
|
Out: {item.clock_out || (item.status === 'APPROVED' ? 'Aktif' : '-')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400 font-mono">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Reason & Attachment */}
|
||||||
|
<td className="py-3.5 px-4 max-w-xs">
|
||||||
|
<p className="text-xs text-slate-700 line-clamp-2">
|
||||||
|
{item.reason_or_notes || '-'}
|
||||||
|
</p>
|
||||||
|
{item.attachment_url && (
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<a
|
||||||
|
href={item.attachment_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-brand-light text-brand-primary text-[11px] font-bold hover:bg-brand-pastel/50 transition-colors"
|
||||||
|
title="Buka / Lihat Lampiran"
|
||||||
|
>
|
||||||
|
<Eye className="w-3 h-3" /> Lihat
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={item.attachment_url}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-lg bg-slate-100 text-slate-700 text-[11px] font-bold hover:bg-slate-200 transition-colors"
|
||||||
|
title="Unduh File Lampiran"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" /> Unduh
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
item.status === 'APPROVED'
|
||||||
|
? 'present'
|
||||||
|
: item.status === 'PENDING'
|
||||||
|
? 'pending'
|
||||||
|
: 'terminated'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.status}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* HR Superadmin Actions */}
|
||||||
|
{isSuperadmin && (
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{item.status === 'PENDING' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdateStatus(item.id, 'APPROVED')}
|
||||||
|
className="p-1.5 text-emerald-600 hover:bg-emerald-50 rounded-xl transition-colors"
|
||||||
|
title="Setujui Pengajuan"
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onUpdateStatus(item.id, 'REJECTED')}
|
||||||
|
className="p-1.5 text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||||
|
title="Tolak Pengajuan"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||||
|
title="Hapus Record"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<ClockActionWidgetProps> = ({
|
||||||
|
todayAttendance,
|
||||||
|
onRefresh,
|
||||||
|
}) => {
|
||||||
|
const { user, employee } = useAuth();
|
||||||
|
const [workMode, setWorkMode] = useState<WorkMode>('WFO');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [timerString, setTimerString] = useState<string>('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 (
|
||||||
|
<Card className="bg-gradient-to-br from-brand-primary via-brand-secondary to-brand-hover text-white shadow-brand-glow p-6 sm:p-7 relative overflow-hidden">
|
||||||
|
{/* Glow decorative */}
|
||||||
|
<div className="absolute top-0 right-0 w-48 h-48 bg-white/10 rounded-full blur-2xl pointer-events-none -mr-12 -mt-12" />
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-white/20">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/20 text-white text-[11px] font-bold uppercase tracking-wider backdrop-blur-sm">
|
||||||
|
<Clock className="w-3.5 h-3.5" /> Staff Clock Widget
|
||||||
|
</span>
|
||||||
|
<h3 className="text-xl font-black text-white mt-2 tracking-tight">
|
||||||
|
Presensi Kehadiran Harian
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-white/80 mt-0.5">
|
||||||
|
{employee?.full_name} • {employee?.position || 'Staff Account'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Pills */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isClockedIn ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-emerald-400 text-emerald-950 text-xs font-black shadow-sm">
|
||||||
|
<CheckCircle2 className="w-4 h-4" /> Sedang Aktif Bekerja
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-white/20 text-white text-xs font-bold">
|
||||||
|
Belum Clock-In
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Controls */}
|
||||||
|
<div className="mt-5 grid grid-cols-1 md:grid-cols-3 gap-5 items-center">
|
||||||
|
{/* Work Mode Selector */}
|
||||||
|
{!isClockedIn && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-white/90 uppercase tracking-wider">
|
||||||
|
Pilih Lokasi Kerja:
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkMode('WFO')}
|
||||||
|
className={`py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 transition-all ${
|
||||||
|
workMode === 'WFO'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'bg-white/10 text-white hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Building2 className="w-4 h-4" /> WFO (Office)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWorkMode('WFH')}
|
||||||
|
className={`py-2 px-3 rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 transition-all ${
|
||||||
|
workMode === 'WFH'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'bg-white/10 text-white hover:bg-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" /> WFH (Home)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clock In info / live counter */}
|
||||||
|
{isClockedIn && (
|
||||||
|
<div className="space-y-1 bg-white/10 p-3.5 rounded-2xl backdrop-blur-sm border border-white/20">
|
||||||
|
<span className="text-[11px] font-semibold text-white/80 uppercase tracking-wider">
|
||||||
|
Waktu Masuk & Durasi
|
||||||
|
</span>
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
|
<span className="text-xl font-bold font-mono text-white">
|
||||||
|
{todayAttendance?.clock_in}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-mono text-emerald-300 font-bold">
|
||||||
|
{isClockedOut ? `Selesai (${todayAttendance?.duration_minutes}m)` : `Durasi: ${timerString}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Activity note input */}
|
||||||
|
{!isClockedIn && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-bold text-white/90 uppercase tracking-wider">
|
||||||
|
Catatan Rencana Kerja:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Primary Action Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
{!isClockedIn ? (
|
||||||
|
<Button
|
||||||
|
variant="pastel"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleClockIn}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
className="w-full md:w-auto font-black shadow-lg"
|
||||||
|
>
|
||||||
|
<LogIn className="w-5 h-5" /> Clock In Sekarang ({workMode})
|
||||||
|
</Button>
|
||||||
|
) : !isClockedOut ? (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="lg"
|
||||||
|
onClick={handleClockOut}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
className="w-full md:w-auto font-bold shadow-lg"
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5" /> Selesaikan Shift (Clock Out)
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="text-right">
|
||||||
|
<span className="text-xs font-semibold text-white/90 block">
|
||||||
|
Clock-out pukul: <strong className="font-mono">{todayAttendance?.clock_out}</strong>
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-emerald-300 font-bold">
|
||||||
|
Total Bekerja: {todayAttendance?.duration_minutes ?? 0} Menit
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedPermitModal: React.FC<UnifiedPermitModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
employees,
|
||||||
|
currentEmployee,
|
||||||
|
isSuperadmin,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [employeeId, setEmployeeId] = useState(currentEmployee?.id || employees[0]?.id || '');
|
||||||
|
const [type, setType] = useState<AttendanceType>('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<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Pengajuan Berita Acara / Cuti / Izin"
|
||||||
|
subtitle="Form terpadu untuk pengajuan cuti, sakit, izin telat, izin pulang cepat, & dinas luar"
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Superadmin Employee Selector */}
|
||||||
|
{isSuperadmin ? (
|
||||||
|
<Select
|
||||||
|
label="Pilih Karyawan"
|
||||||
|
value={employeeId}
|
||||||
|
onChange={(e) => setEmployeeId(e.target.value)}
|
||||||
|
options={employees.map((emp) => ({
|
||||||
|
value: emp.id,
|
||||||
|
label: `${emp.full_name} (${emp.department} - ${emp.nik})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-slate-50 rounded-xl border border-slate-200 text-xs">
|
||||||
|
<span className="text-slate-400 block">Pemohon:</span>
|
||||||
|
<strong className="text-slate-800 text-sm">{currentEmployee?.full_name}</strong> ({currentEmployee?.position})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Jenis Pengajuan / Berita Acara"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => 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' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Tanggal Pelaksanaan"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(e) => setDate(e.target.value)}
|
||||||
|
icon={<Calendar className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{type === 'LATE_PERMIT' && (
|
||||||
|
<Input
|
||||||
|
label="Estimasi Keterlambatan (Menit)"
|
||||||
|
type="number"
|
||||||
|
value={lateMinutes}
|
||||||
|
onChange={(e) => setLateMinutes(Number(e.target.value))}
|
||||||
|
placeholder="30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 tracking-wide mb-1.5">
|
||||||
|
Alasan / Keterangan Lengkap
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={3}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Jelaskan alasan pengajuan izin/cuti atau agenda dinas luar..."
|
||||||
|
className="w-full px-3.5 py-2 text-sm bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image / Attachment Upload Box */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 tracking-wide">
|
||||||
|
Unggah Bukti / Lampiran Gambar (Disimpan di Server Publik)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
accept="image/*,.pdf,.txt"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!attachmentUrl ? (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={`p-5 border-2 border-dashed rounded-2xl flex flex-col items-center justify-center gap-2 cursor-pointer transition-all ${
|
||||||
|
isUploading
|
||||||
|
? 'bg-slate-50 border-slate-300'
|
||||||
|
: 'border-slate-300 hover:border-brand-primary bg-slate-50/50 hover:bg-brand-light/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isUploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-brand-primary" />
|
||||||
|
<span className="text-xs font-semibold text-slate-600">
|
||||||
|
Mengunggah file ke /public/uploads/...
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-2.5 rounded-xl bg-brand-light text-brand-primary">
|
||||||
|
<UploadCloud className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs font-bold text-slate-800">
|
||||||
|
Klik untuk Pilih Gambar / Foto Surat Dokter / Bukti Lampiran
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||||
|
Mendukung format PNG, JPG, JPEG, WEBP, PDF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3.5 rounded-2xl bg-emerald-50/80 border border-emerald-200 flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-emerald-100 text-emerald-700">
|
||||||
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-xs font-bold text-slate-900 truncate">
|
||||||
|
File Lampiran Berhasil Diunggah
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={attachmentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[11px] text-brand-primary font-mono hover:underline flex items-center gap-1 mt-0.5 truncate"
|
||||||
|
>
|
||||||
|
<span>{attachmentUrl}</span>
|
||||||
|
<ExternalLink className="w-3 h-3 shrink-0" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAttachment}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||||
|
title="Hapus Lampiran"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadError && (
|
||||||
|
<p className="text-xs text-red-500 font-medium">{uploadError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting || isUploading}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" isLoading={isSubmitting} disabled={isUploading}>
|
||||||
|
Ajukan Sekarang
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Calendar, Filter, ArrowRight } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
interface DateFilterBarProps {
|
||||||
|
currentPreset: string;
|
||||||
|
currentFrom: string;
|
||||||
|
currentTo: string;
|
||||||
|
onFilterChange: (preset: string, from?: string, to?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DateFilterBar: React.FC<DateFilterBarProps> = ({
|
||||||
|
currentPreset,
|
||||||
|
currentFrom,
|
||||||
|
currentTo,
|
||||||
|
onFilterChange,
|
||||||
|
}) => {
|
||||||
|
const [isCustom, setIsCustom] = useState(currentPreset === 'custom');
|
||||||
|
const [fromVal, setFromVal] = useState(currentFrom);
|
||||||
|
const [toVal, setToVal] = useState(currentTo);
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{ id: 'this_month', label: 'Bulan Ini' },
|
||||||
|
{ id: 'last_month', label: 'Bulan Lalu' },
|
||||||
|
{ id: 'last_30_days', label: '30 Hari Terakhir' },
|
||||||
|
{ id: 'all_time', label: 'Semua Periode' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelectPreset = (presetId: string) => {
|
||||||
|
setIsCustom(false);
|
||||||
|
onFilterChange(presetId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomApply = () => {
|
||||||
|
if (fromVal && toVal) {
|
||||||
|
setIsCustom(true);
|
||||||
|
onFilterChange('custom', fromVal, toVal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-3 sm:p-4 rounded-2xl border border-slate-200/90 shadow-sm flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4">
|
||||||
|
{/* Presets List */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider mr-2 flex items-center gap-1.5">
|
||||||
|
<Filter className="w-3.5 h-3.5 text-brand-primary" /> Filter Periode:
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{presets.map((p) => {
|
||||||
|
const isSelected = !isCustom && currentPreset === p.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleSelectPreset(p.id)}
|
||||||
|
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-brand-primary text-white shadow-sm shadow-brand-glow'
|
||||||
|
: 'bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-slate-200/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCustom(true)}
|
||||||
|
className={`px-3.5 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
isCustom
|
||||||
|
? 'bg-brand-primary text-white shadow-sm shadow-brand-glow'
|
||||||
|
: 'bg-slate-50 text-slate-600 hover:bg-slate-100 hover:text-slate-900 border border-slate-200/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom Tanggal (Date to Date)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Inputs */}
|
||||||
|
{isCustom && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 pt-3 lg:pt-0 border-t lg:border-t-0 border-slate-100">
|
||||||
|
<div className="flex items-center gap-2 bg-slate-50 px-3 py-1.5 rounded-xl border border-slate-200/80">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={fromVal}
|
||||||
|
onChange={(e) => setFromVal(e.target.value)}
|
||||||
|
className="bg-transparent text-xs font-semibold text-slate-800 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<ArrowRight className="w-3 h-3 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={toVal}
|
||||||
|
onChange={(e) => setToVal(e.target.value)}
|
||||||
|
className="bg-transparent text-xs font-semibold text-slate-800 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCustomApply}>
|
||||||
|
Terapkan
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { DailyStatusCount } from '@/types/dashboard';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
CheckCircle2,
|
||||||
|
Home,
|
||||||
|
Building2,
|
||||||
|
FileHeart,
|
||||||
|
CalendarDays,
|
||||||
|
AlarmClock,
|
||||||
|
Plane,
|
||||||
|
AlertCircle,
|
||||||
|
ExternalLink,
|
||||||
|
Download,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface DayStatusFeedProps {
|
||||||
|
todayStatus: DailyStatusCount;
|
||||||
|
yesterdayStatus: DailyStatusCount;
|
||||||
|
tomorrowStatus: DailyStatusCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DayStatusFeed: React.FC<DayStatusFeedProps> = ({
|
||||||
|
todayStatus,
|
||||||
|
yesterdayStatus,
|
||||||
|
tomorrowStatus,
|
||||||
|
}) => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'today' | 'yesterday' | 'tomorrow'>('today');
|
||||||
|
|
||||||
|
const currentData =
|
||||||
|
activeTab === 'today'
|
||||||
|
? todayStatus
|
||||||
|
: activeTab === 'yesterday'
|
||||||
|
? yesterdayStatus
|
||||||
|
: tomorrowStatus;
|
||||||
|
|
||||||
|
const formatDateLabel = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-slate-200/90 shadow-sm">
|
||||||
|
{/* Tab Selector Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-brand-primary" />
|
||||||
|
<h3 className="font-extrabold text-slate-900 text-lg tracking-tight">
|
||||||
|
Feed Status Kehadiran & Izin
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{formatDateLabel(currentData.date)} • Total {currentData.totalEmployees} Karyawan Aktif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Segmented Control */}
|
||||||
|
<div className="flex items-center bg-slate-100 p-1 rounded-2xl border border-slate-200/80">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('yesterday')}
|
||||||
|
className={`px-4 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'yesterday'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Kemarin
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('today')}
|
||||||
|
className={`px-4 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'today'
|
||||||
|
? 'bg-brand-primary text-white shadow-sm shadow-brand-glow'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hari Ini
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('tomorrow')}
|
||||||
|
className={`px-4 py-1.5 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
activeTab === 'tomorrow'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'text-slate-600 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Besok (Rencana)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics Row */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 my-5">
|
||||||
|
<div className="p-3.5 rounded-2xl bg-emerald-50/60 border border-emerald-200/70">
|
||||||
|
<div className="flex items-center justify-between text-emerald-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Hadir</span>
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-baseline gap-1.5">
|
||||||
|
<span className="text-2xl font-black text-emerald-800 font-mono">
|
||||||
|
{currentData.presentCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-emerald-600 font-semibold">
|
||||||
|
({currentData.wfoCount} WFO / {currentData.wfhCount} WFH)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-2xl bg-purple-50/60 border border-purple-200/70">
|
||||||
|
<div className="flex items-center justify-between text-purple-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Cuti</span>
|
||||||
|
<CalendarDays className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-2xl font-black text-purple-800 font-mono">
|
||||||
|
{currentData.leaveCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-2xl bg-rose-50/60 border border-rose-200/70">
|
||||||
|
<div className="flex items-center justify-between text-rose-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Sakit</span>
|
||||||
|
<FileHeart className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-2xl font-black text-rose-800 font-mono">
|
||||||
|
{currentData.sickCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-2xl bg-amber-50/60 border border-amber-200/70">
|
||||||
|
<div className="flex items-center justify-between text-amber-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Izin / Telat</span>
|
||||||
|
<AlarmClock className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-2xl font-black text-amber-800 font-mono">
|
||||||
|
{currentData.lateCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-2xl bg-cyan-50/60 border border-cyan-200/70">
|
||||||
|
<div className="flex items-center justify-between text-cyan-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Dinas Luar</span>
|
||||||
|
<Plane className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-2xl font-black text-cyan-800 font-mono">
|
||||||
|
{currentData.officialTravelCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-2xl bg-yellow-50/60 border border-yellow-200/70">
|
||||||
|
<div className="flex items-center justify-between text-yellow-700">
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider">Pending</span>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="text-2xl font-black text-yellow-800 font-mono">
|
||||||
|
{currentData.pendingCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breakdown Feed Columns */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||||
|
{/* On Leave / Sick / Late Section */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-50/70 border border-slate-200/80">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-200/60">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700">
|
||||||
|
Absen Tidak Hadir / Izin / Cuti
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs font-bold text-slate-500 font-mono">
|
||||||
|
{currentData.employees.leave.length +
|
||||||
|
currentData.employees.sick.length +
|
||||||
|
currentData.employees.late.length +
|
||||||
|
currentData.employees.travel.length}{' '}
|
||||||
|
Orang
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 mt-2 space-y-1 max-h-72 overflow-y-auto pr-1">
|
||||||
|
{currentData.employees.leave.length === 0 &&
|
||||||
|
currentData.employees.sick.length === 0 &&
|
||||||
|
currentData.employees.late.length === 0 &&
|
||||||
|
currentData.employees.travel.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-xs text-slate-400">
|
||||||
|
Tidak ada izin, sakit, atau cuti yang tercatat untuk tanggal ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Leave */}
|
||||||
|
{currentData.employees.leave.map(({ employee, record }) => (
|
||||||
|
<div key={record.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar src={employee.photo_url} name={employee.full_name} size="sm" status="leave" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-900">{employee.full_name}</p>
|
||||||
|
<p className="text-[11px] text-slate-500 line-clamp-1">{record.reason_or_notes || 'Cuti Tahunan'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="leave" size="sm">CUTI</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sick */}
|
||||||
|
{currentData.employees.sick.map(({ employee, record }) => (
|
||||||
|
<div key={record.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar src={employee.photo_url} name={employee.full_name} size="sm" status="probation" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-900">{employee.full_name}</p>
|
||||||
|
<p className="text-[11px] text-rose-600 line-clamp-1">{record.reason_or_notes || 'Sakit'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{record.attachment_url && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={record.attachment_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="p-1 rounded-lg bg-rose-100 text-rose-700 hover:bg-rose-200 transition-colors"
|
||||||
|
title="Lihat Surat Dokter"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={record.attachment_url}
|
||||||
|
download
|
||||||
|
className="p-1 rounded-lg bg-slate-100 text-slate-700 hover:bg-slate-200 transition-colors"
|
||||||
|
title="Unduh Surat Dokter"
|
||||||
|
>
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Badge variant="sick" size="sm">SAKIT</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Late */}
|
||||||
|
{currentData.employees.late.map(({ employee, record }) => (
|
||||||
|
<div key={record.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar src={employee.photo_url} name={employee.full_name} size="sm" status="probation" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-900">{employee.full_name}</p>
|
||||||
|
<p className="text-[11px] text-amber-700 line-clamp-1">
|
||||||
|
Masuk: {record.clock_in?.slice(0, 5) || '-'} • Telat {record.late_minutes}m ({record.reason_or_notes || 'Izin terlambat'})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="late" size="sm">TELAT</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Travel */}
|
||||||
|
{currentData.employees.travel.map(({ employee, record }) => (
|
||||||
|
<div key={record.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar src={employee.photo_url} name={employee.full_name} size="sm" status="active" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-900">{employee.full_name}</p>
|
||||||
|
<p className="text-[11px] text-cyan-700 line-clamp-1">{record.reason_or_notes || 'Dinas Luar'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="travel" size="sm">DINAS</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* On Duty / Present Section */}
|
||||||
|
<div className="p-4 rounded-2xl bg-slate-50/70 border border-slate-200/80">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-slate-200/60">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-700">
|
||||||
|
Sedang Bertugas / Hadir Hari Ini
|
||||||
|
</h4>
|
||||||
|
<span className="text-xs font-bold text-emerald-700 font-mono">
|
||||||
|
{currentData.presentCount} Hadir
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-slate-100 mt-2 space-y-1 max-h-72 overflow-y-auto pr-1">
|
||||||
|
{currentData.employees.present.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-xs text-slate-400">
|
||||||
|
Belum ada karyawan yang tercatat hadir untuk tanggal ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
currentData.employees.present.map(({ employee, record }) => (
|
||||||
|
<div key={record.id} className="py-2 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Avatar src={employee.photo_url} name={employee.full_name} size="sm" status="present" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-slate-900">{employee.full_name}</p>
|
||||||
|
<p className="text-[11px] text-slate-500 font-mono">
|
||||||
|
In: {record.clock_in?.slice(0, 5) || '-'} • Out: {record.clock_out?.slice(0, 5) || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Badge variant={record.work_mode === 'WFO' ? 'wfo' : 'wfh'} size="sm">
|
||||||
|
{record.work_mode === 'WFO' ? (
|
||||||
|
<span className="flex items-center gap-1"><Building2 className="w-3 h-3" /> WFO</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1"><Home className="w-3 h-3" /> WFH</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { EarlyBirdHighlight } from '@/types/dashboard';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Sun, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EarlyBirdCardProps {
|
||||||
|
data: EarlyBirdHighlight | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EarlyBirdCard: React.FC<EarlyBirdCardProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden bg-gradient-to-br from-brand-pastel/25 via-white to-brand-light/30 border-brand-pastel/70 shadow-sm flex flex-col justify-between">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-amber-100/80 text-amber-700 border border-amber-200">
|
||||||
|
<Sun className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-brand-primary text-white text-[11px] font-bold uppercase tracking-wider shadow-sm">
|
||||||
|
<Sparkles className="w-3 h-3" /> Si Paling Pagi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{data ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={data.employee.photo_url}
|
||||||
|
name={data.employee.full_name}
|
||||||
|
size="xl"
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-extrabold text-slate-900 line-clamp-1">
|
||||||
|
{data.employee.full_name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 line-clamp-1">
|
||||||
|
{data.employee.position}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-black text-brand-primary font-mono tracking-tight">
|
||||||
|
{data.averageClockIn}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
Rata-rata Masuk
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-slate-400 text-xs">
|
||||||
|
Belum ada record absensi pagi.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>Rekor Terpagi: <strong className="text-slate-800 font-mono">{data?.earliestClockIn || '-'}</strong></span>
|
||||||
|
<span>Total: <strong className="text-brand-primary font-mono">{data?.count || 0}x</strong> hadir</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { LatecomerHighlight } from '@/types/dashboard';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { AlarmClock, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LatecomerCardProps {
|
||||||
|
data: LatecomerHighlight | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LatecomerCard: React.FC<LatecomerCardProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden bg-gradient-to-br from-amber-50/70 via-white to-orange-50/40 border-amber-200/80 shadow-sm flex flex-col justify-between">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-amber-100 text-amber-800 border border-amber-200">
|
||||||
|
<AlarmClock className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-amber-500 text-white text-[11px] font-bold uppercase tracking-wider shadow-sm">
|
||||||
|
<AlertCircle className="w-3 h-3" /> Si Paling Telat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{data && data.totalLateMinutes > 0 ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={data.employee.photo_url}
|
||||||
|
name={data.employee.full_name}
|
||||||
|
size="xl"
|
||||||
|
status="probation"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-extrabold text-slate-900 line-clamp-1">
|
||||||
|
{data.employee.full_name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 line-clamp-1">
|
||||||
|
{data.employee.position}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-black text-amber-600 font-mono tracking-tight">
|
||||||
|
{data.totalLateMinutes} <span className="text-sm font-semibold">menit</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
Total Terlambat
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-slate-400 text-xs">
|
||||||
|
Tidak ada keterlambatan tercatat pada periode ini.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>Keterlambatan: <strong className="text-amber-700 font-mono">{data?.lateCount || 0}x</strong></span>
|
||||||
|
<span>Rekor Terlama: <strong className="text-red-600 font-mono">{data?.latestLateMinutes || 0}m</strong></span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { LeaderboardUser } from '@/types/dashboard';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import confetti from 'canvas-confetti';
|
||||||
|
import { Flame, Trophy, Award, Medal, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LeaderboardCardProps {
|
||||||
|
leaderboard: LeaderboardUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LeaderboardCard: React.FC<LeaderboardCardProps> = ({
|
||||||
|
leaderboard,
|
||||||
|
}) => {
|
||||||
|
const triggerConfetti = () => {
|
||||||
|
confetti({
|
||||||
|
particleCount: 80,
|
||||||
|
spread: 70,
|
||||||
|
origin: { y: 0.6 },
|
||||||
|
colors: ['#1b4ef5', '#3874ff', '#5996ff', '#f4ceff', '#fbbf24'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRankBadge = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-amber-400 text-amber-950 flex items-center justify-center font-black text-xs shadow-sm ring-2 ring-amber-200">
|
||||||
|
<Trophy className="w-4 h-4 text-amber-900" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-slate-300 text-slate-900 flex items-center justify-center font-black text-xs shadow-sm ring-2 ring-slate-200">
|
||||||
|
<Medal className="w-4 h-4 text-slate-800" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-amber-700/80 text-white flex items-center justify-center font-black text-xs shadow-sm ring-2 ring-amber-600/30">
|
||||||
|
<Award className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="w-7 h-7 rounded-xl bg-slate-100 text-slate-600 flex items-center justify-center font-bold text-xs">
|
||||||
|
#{rank}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="h-full flex flex-col justify-between border-slate-200/90 relative overflow-hidden bg-gradient-to-b from-white via-white to-brand-light/10">
|
||||||
|
{/* Background Decorative Accent */}
|
||||||
|
<div className="absolute top-0 right-0 w-36 h-36 bg-gradient-to-br from-brand-pastel/30 to-brand-primary/10 rounded-full blur-2xl pointer-events-none -mr-10 -mt-10" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-slate-100">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-2 rounded-2xl bg-gradient-to-tr from-orange-500 to-amber-500 text-white shadow-sm">
|
||||||
|
<Flame className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-extrabold text-slate-900 text-base tracking-tight">
|
||||||
|
Top 5 "Si Paling Rajin"
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-brand-pastel/40 text-brand-dark font-bold">
|
||||||
|
Streak Champions
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Paling konsisten hadir tepat waktu & tanpa unexcused absence
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={triggerConfetti}
|
||||||
|
title="Celebrate champions!"
|
||||||
|
className="p-2 rounded-xl bg-brand-light/60 hover:bg-brand-pastel/40 text-brand-primary transition-all duration-150 active:scale-95"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List of top 5 */}
|
||||||
|
<div className="divide-y divide-slate-100 mt-2">
|
||||||
|
{leaderboard.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-slate-400 text-sm">
|
||||||
|
Belum ada data kehadiran pada periode tanggal yang dipilih.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
leaderboard.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.employee.id}
|
||||||
|
className="py-3 px-2 flex items-center justify-between rounded-xl hover:bg-slate-50/80 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{getRankBadge(item.rank)}
|
||||||
|
<Avatar
|
||||||
|
src={item.employee.photo_url}
|
||||||
|
name={item.employee.full_name}
|
||||||
|
size="md"
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-bold text-slate-900">
|
||||||
|
{item.employee.full_name}
|
||||||
|
</p>
|
||||||
|
{item.streakCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-[11px] font-bold text-orange-600 bg-orange-50 px-2 py-0.5 rounded-full border border-orange-200">
|
||||||
|
<Flame className="w-3 h-3 fill-orange-500 text-orange-500" />
|
||||||
|
{item.streakCount}d streak
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{item.employee.position} • {item.employee.department}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<span className="text-base font-black text-brand-primary font-mono">
|
||||||
|
{item.presentCount}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-400 font-medium">hari hadir</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-emerald-600 font-semibold">
|
||||||
|
{item.onTimeCount}x Tepat Waktu
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>Kalkulasi: 10 pts/on-time, -2 pts/late minute</span>
|
||||||
|
<span className="font-semibold text-brand-primary">Update Otomatis</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { NightOwlHighlight } from '@/types/dashboard';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Card } from '@/components/ui/Card';
|
||||||
|
import { Moon, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NightOwlCardProps {
|
||||||
|
data: NightOwlHighlight | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NightOwlCard: React.FC<NightOwlCardProps> = ({ data }) => {
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden bg-gradient-to-br from-indigo-50/70 via-white to-brand-light/40 border-indigo-200/80 shadow-sm flex flex-col justify-between">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="p-2.5 rounded-2xl bg-indigo-100 text-indigo-700 border border-indigo-200">
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-indigo-600 text-white text-[11px] font-bold uppercase tracking-wider shadow-sm">
|
||||||
|
<Sparkles className="w-3 h-3" /> Si Paling Pulang Malam
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{data ? (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={data.employee.photo_url}
|
||||||
|
name={data.employee.full_name}
|
||||||
|
size="xl"
|
||||||
|
status="active"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-extrabold text-slate-900 line-clamp-1">
|
||||||
|
{data.employee.full_name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 line-clamp-1">
|
||||||
|
{data.employee.position}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-baseline gap-2">
|
||||||
|
<span className="text-2xl font-black text-indigo-600 font-mono tracking-tight">
|
||||||
|
{data.averageClockOut}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 font-medium">
|
||||||
|
Rata-rata Pulang
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-slate-400 text-xs">
|
||||||
|
Belum ada record absensi pulang.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>Rekor Termalam: <strong className="text-indigo-800 font-mono">{data?.latestClockOut || '-'}</strong></span>
|
||||||
|
<span>Total: <strong className="text-indigo-600 font-mono">{data?.count || 0}x</strong> shift</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { EmployeeContract, ContractInput, ContractType } from '@/types/contract';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Trash2,
|
||||||
|
ExternalLink,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
UploadCloud,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContractTimelineProps {
|
||||||
|
employeeId: string;
|
||||||
|
contracts: EmployeeContract[];
|
||||||
|
isSuperadmin: boolean;
|
||||||
|
onAddContract: (contract: ContractInput) => Promise<void>;
|
||||||
|
onDeleteContract: (contractId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContractTimeline: React.FC<ContractTimelineProps> = ({
|
||||||
|
employeeId,
|
||||||
|
contracts,
|
||||||
|
isSuperadmin,
|
||||||
|
onAddContract,
|
||||||
|
onDeleteContract,
|
||||||
|
}) => {
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<ContractInput>({
|
||||||
|
employee_id: employeeId,
|
||||||
|
contract_number: `0${contracts.length + 1}/EIG-HR/PKWT/2026`,
|
||||||
|
contract_type: 'PKWT',
|
||||||
|
start_date: new Date().toISOString().slice(0, 10),
|
||||||
|
end_date: '',
|
||||||
|
salary: 15000000,
|
||||||
|
notes: '',
|
||||||
|
document_url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatRupiah = (num: number) => {
|
||||||
|
return new Intl.NumberFormat('id-ID', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'IDR',
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(num);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onAddContract({
|
||||||
|
...formData,
|
||||||
|
employee_id: employeeId,
|
||||||
|
});
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setFormData({
|
||||||
|
employee_id: employeeId,
|
||||||
|
contract_number: `0${contracts.length + 2}/EIG-HR/PKWT/2026`,
|
||||||
|
contract_type: 'PKWT',
|
||||||
|
start_date: new Date().toISOString().slice(0, 10),
|
||||||
|
end_date: '',
|
||||||
|
salary: 15000000,
|
||||||
|
notes: '',
|
||||||
|
document_url: '',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-slate-200/90 shadow-sm space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-5 h-5 text-brand-primary" />
|
||||||
|
<h3 className="font-extrabold text-slate-900 text-lg tracking-tight">
|
||||||
|
Riwayat & Timeline Kontrak Karyawan
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
|
Kronologis status ketenagakerjaan dari awal masuk hingga saat ini
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperadmin && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="self-start sm:self-auto"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Tambah Kontrak Baru
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline List */}
|
||||||
|
{contracts.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-slate-400 text-sm">
|
||||||
|
Belum ada riwayat kontrak yang terdaftar untuk karyawan ini.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative pl-6 space-y-8 before:absolute before:left-2.5 before:top-3 before:bottom-3 before:w-0.5 before:bg-brand-light">
|
||||||
|
{contracts.map((contract, index) => {
|
||||||
|
const isLatest = index === contracts.length - 1;
|
||||||
|
const isPermanent = contract.contract_type === 'PKWTT' || !contract.end_date;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={contract.id} className="relative group">
|
||||||
|
{/* Dot */}
|
||||||
|
<div
|
||||||
|
className={`absolute -left-6 top-1.5 w-5 h-5 rounded-full border-2 bg-white flex items-center justify-center transition-transform group-hover:scale-110 ${
|
||||||
|
isLatest
|
||||||
|
? 'border-brand-primary ring-4 ring-brand-primary/10'
|
||||||
|
: 'border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
isLatest ? 'bg-brand-primary' : 'bg-slate-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contract Card */}
|
||||||
|
<div
|
||||||
|
className={`p-4 sm:p-5 rounded-2xl border transition-all ${
|
||||||
|
isLatest
|
||||||
|
? 'bg-gradient-to-br from-white via-white to-brand-light/30 border-brand-pastel/80 shadow-sm'
|
||||||
|
: 'bg-slate-50/70 border-slate-200/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 border-b border-slate-100">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-extrabold text-slate-900 text-sm">
|
||||||
|
{contract.contract_number}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
contract.contract_type === 'PKWTT'
|
||||||
|
? 'brand'
|
||||||
|
: contract.contract_type === 'PKWT'
|
||||||
|
? 'wfo'
|
||||||
|
: 'neutral'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{contract.contract_type}
|
||||||
|
</Badge>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="text-[10px] font-extrabold uppercase px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-800 flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3" /> Kontrak Aktif
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{contract.document_url && (
|
||||||
|
<a
|
||||||
|
href={contract.document_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-brand-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" /> Dokumen
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSuperadmin && (
|
||||||
|
<button
|
||||||
|
onClick={() => onDeleteContract(contract.id)}
|
||||||
|
className="p-1 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title="Hapus Kontrak"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block font-medium">Periode Kontrak</span>
|
||||||
|
<p className="font-bold text-slate-800 mt-0.5 flex items-center gap-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-brand-primary" />
|
||||||
|
<span>{contract.start_date} → {contract.end_date || 'Permanen (PKWTT)'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block font-medium">Gaji Pokok / Compensation</span>
|
||||||
|
<p className="font-extrabold text-brand-primary mt-0.5 font-mono">
|
||||||
|
{formatRupiah(contract.salary)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 block font-medium">Catatan / Keterangan</span>
|
||||||
|
<p className="text-slate-600 mt-0.5 line-clamp-2">
|
||||||
|
{contract.notes || '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Contract Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
title="Tambah Riwayat Kontrak Baru"
|
||||||
|
subtitle="Lampirkan nomor kontrak, skema PKWT/PKWTT, dan kompensasi gaji"
|
||||||
|
maxWidth="lg"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleAddSubmit} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Nomor Surat Perjanjian Kerja / Kontrak"
|
||||||
|
required
|
||||||
|
value={formData.contract_number}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contract_number: e.target.value })}
|
||||||
|
placeholder="001/EIG-HR/PKWT/2026"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Select
|
||||||
|
label="Tipe Kontrak"
|
||||||
|
value={formData.contract_type}
|
||||||
|
onChange={(e) => setFormData({ ...formData, contract_type: e.target.value as ContractType })}
|
||||||
|
options={[
|
||||||
|
{ value: 'PKWT', label: 'PKWT (Kontrak Waktu Tertentu)' },
|
||||||
|
{ value: 'PKWTT', label: 'PKWTT (Karyawan Tetap / Permanen)' },
|
||||||
|
{ value: 'INTERNSHIP', label: 'INTERNSHIP (Magang)' },
|
||||||
|
{ value: 'FREELANCE', label: 'FREELANCE / Mitra' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Gaji Pokok Bulanan (IDR)"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
value={formData.salary}
|
||||||
|
onChange={(e) => setFormData({ ...formData, salary: Number(e.target.value) })}
|
||||||
|
placeholder="15000000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Tanggal Mulai Kontrak"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={formData.start_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, start_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Tanggal Berakhir (Kosongkan jika PKWTT)"
|
||||||
|
type="date"
|
||||||
|
value={formData.end_date || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, end_date: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 tracking-wide">
|
||||||
|
File Dokumen Perjanjian Kerja / PDF Kontrak
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.document_url || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
|
placeholder="https://... atau unggah file di samping"
|
||||||
|
className="flex-1 px-3.5 py-2 text-xs bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary font-mono"
|
||||||
|
/>
|
||||||
|
<label className="cursor-pointer inline-flex items-center gap-1.5 px-3 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-xs font-semibold transition-colors">
|
||||||
|
<UploadCloud className="w-4 h-4 text-brand-primary" />
|
||||||
|
<span>Upload</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*,.pdf,.doc,.docx,.txt"
|
||||||
|
className="hidden"
|
||||||
|
onChange={async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
const res = await fetch('/api/upload', { method: 'POST', body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.url) {
|
||||||
|
setFormData((prev) => ({ ...prev, document_url: data.url }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 tracking-wide mb-1.5">
|
||||||
|
Catatan Khusus / Job Description Remarks
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={formData.notes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
|
placeholder="Keterangan perpanjangan kontrak, promosi jabatan, dsb..."
|
||||||
|
className="w-full px-3.5 py-2 text-sm bg-white border border-slate-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" isLoading={isSubmitting}>
|
||||||
|
Simpan Kontrak
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Employee, EmployeeInput } from '@/types/employee';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { Select } from '@/components/ui/Select';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { AvatarPickerModal } from '@/components/ui/AvatarPickerModal';
|
||||||
|
import { getRandomAvatarPreset } from '@/lib/avatar';
|
||||||
|
import { Sparkles, Shuffle, User, Mail, Phone, Building2, Briefcase, Calendar, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmployeeFormModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
employeeToEdit: Employee | null;
|
||||||
|
onSave: (data: EmployeeInput) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmployeeFormModal: React.FC<EmployeeFormModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
employeeToEdit,
|
||||||
|
onSave,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<EmployeeInput>({
|
||||||
|
nik: '',
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
department: 'Engineering',
|
||||||
|
position: '',
|
||||||
|
status: 'active',
|
||||||
|
join_date: new Date().toISOString().slice(0, 10),
|
||||||
|
work_location_default: 'WFO',
|
||||||
|
photo_url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isPickerOpen, setIsPickerOpen] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (employeeToEdit) {
|
||||||
|
setFormData({
|
||||||
|
nik: employeeToEdit.nik,
|
||||||
|
full_name: employeeToEdit.full_name,
|
||||||
|
email: employeeToEdit.email,
|
||||||
|
phone: employeeToEdit.phone,
|
||||||
|
department: employeeToEdit.department,
|
||||||
|
position: employeeToEdit.position,
|
||||||
|
status: employeeToEdit.status,
|
||||||
|
join_date: employeeToEdit.join_date,
|
||||||
|
work_location_default: employeeToEdit.work_location_default,
|
||||||
|
photo_url: employeeToEdit.photo_url,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const defaultPhoto = getRandomAvatarPreset('NewEmployee');
|
||||||
|
setFormData({
|
||||||
|
nik: `EIG-2026-${Math.floor(100 + Math.random() * 900)}`,
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '+628',
|
||||||
|
department: 'Engineering',
|
||||||
|
position: '',
|
||||||
|
status: 'active',
|
||||||
|
join_date: new Date().toISOString().slice(0, 10),
|
||||||
|
work_location_default: 'WFO',
|
||||||
|
photo_url: defaultPhoto,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [employeeToEdit, isOpen]);
|
||||||
|
|
||||||
|
const handleQuickRandomizePhoto = () => {
|
||||||
|
const newPhoto = getRandomAvatarPreset(formData.full_name || 'Staff');
|
||||||
|
setFormData((prev) => ({ ...prev, photo_url: newPhoto }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onSave(formData);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={employeeToEdit ? 'Edit Data Karyawan' : 'Tambah Karyawan Baru'}
|
||||||
|
subtitle="Kelola profil, NIK, penempatan, dan foto avatar online"
|
||||||
|
maxWidth="2xl"
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Avatar Banner & Online Generator */}
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-r from-brand-light/60 via-white to-brand-pastel/30 border border-brand-pastel/60 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
src={formData.photo_url}
|
||||||
|
name={formData.full_name || 'Preview'}
|
||||||
|
size="xl"
|
||||||
|
status={formData.status}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-brand-primary text-white text-[10px] font-bold uppercase tracking-wider">
|
||||||
|
<Sparkles className="w-3 h-3" /> Online Avatar Generator
|
||||||
|
</span>
|
||||||
|
<p className="text-xs font-bold text-slate-800 mt-1">
|
||||||
|
Foto Karyawan Otomatis
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-500 line-clamp-1 max-w-xs font-mono">
|
||||||
|
{formData.photo_url || 'Belum ada foto'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleQuickRandomizePhoto}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
title="Randomize online avatar look"
|
||||||
|
>
|
||||||
|
<Shuffle className="w-3.5 h-3.5" /> Random
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="pastel"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsPickerOpen(true)}
|
||||||
|
className="w-full sm:w-auto font-bold"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" /> Kustom Avatar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Fields Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Nama Lengkap Karyawan"
|
||||||
|
required
|
||||||
|
value={formData.full_name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
|
||||||
|
placeholder="Contoh: Raden Bagus Arya"
|
||||||
|
icon={<User className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nomor Induk Karyawan (NIK)"
|
||||||
|
required
|
||||||
|
value={formData.nik}
|
||||||
|
onChange={(e) => setFormData({ ...formData, nik: e.target.value })}
|
||||||
|
placeholder="Contoh: EIG-2026-045"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Email Perusahaan"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder="nama@eigen.io"
|
||||||
|
icon={<Mail className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Nomor Telepon / WhatsApp"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
placeholder="+628123456789"
|
||||||
|
icon={<Phone className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Departemen / Divisi"
|
||||||
|
value={formData.department}
|
||||||
|
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||||
|
options={[
|
||||||
|
{ value: 'Engineering', label: 'Engineering' },
|
||||||
|
{ value: 'Product', label: 'Product & Design' },
|
||||||
|
{ value: 'Human Resources', label: 'Human Resources' },
|
||||||
|
{ value: 'Marketing', label: 'Marketing & Growth' },
|
||||||
|
{ value: 'Finance', label: 'Finance & Accounting' },
|
||||||
|
{ value: 'Executive', label: 'Executive' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Jabatan / Posisi"
|
||||||
|
required
|
||||||
|
value={formData.position}
|
||||||
|
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||||
|
placeholder="Contoh: Senior Backend Engineer"
|
||||||
|
icon={<Briefcase className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Status Ketenagakerjaan"
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: 'Active (Karyawan Aktif)' },
|
||||||
|
{ value: 'probation', label: 'Probation (Masa Percobaan)' },
|
||||||
|
{ value: 'resigned', label: 'Resigned (Mengundurkan Diri)' },
|
||||||
|
{ value: 'terminated', label: 'Terminated (Berakhir)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Default Work Mode"
|
||||||
|
value={formData.work_location_default}
|
||||||
|
onChange={(e) => setFormData({ ...formData, work_location_default: e.target.value as any })}
|
||||||
|
options={[
|
||||||
|
{ value: 'WFO', label: 'WFO (Work From Office)' },
|
||||||
|
{ value: 'WFH', label: 'WFH (Work From Home)' },
|
||||||
|
{ value: 'HYBRID', label: 'HYBRID (Fleksibel WFO/WFH)' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Tanggal Masuk (Join Date)"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={formData.join_date}
|
||||||
|
onChange={(e) => setFormData({ ...formData, join_date: e.target.value })}
|
||||||
|
icon={<Calendar className="w-4 h-4" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Custom Photo URL (Opsional)"
|
||||||
|
value={formData.photo_url}
|
||||||
|
onChange={(e) => setFormData({ ...formData, photo_url: e.target.value })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isSubmitting}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" isLoading={isSubmitting}>
|
||||||
|
{employeeToEdit ? 'Simpan Perubahan' : 'Tambah Karyawan'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Avatar Picker Sub-Modal */}
|
||||||
|
<AvatarPickerModal
|
||||||
|
isOpen={isPickerOpen}
|
||||||
|
onClose={() => setIsPickerOpen(false)}
|
||||||
|
employeeName={formData.full_name}
|
||||||
|
currentUrl={formData.photo_url}
|
||||||
|
onSelect={(newUrl) => setFormData((prev) => ({ ...prev, photo_url: newUrl }))}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Employee } from '@/types/employee';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Badge, BadgeVariant } from '@/components/ui/Badge';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Building2,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
Eye,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
MapPin,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmployeeTableProps {
|
||||||
|
employees: Employee[];
|
||||||
|
isSuperadmin: boolean;
|
||||||
|
onEdit: (employee: Employee) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmployeeTable: React.FC<EmployeeTableProps> = ({
|
||||||
|
employees,
|
||||||
|
isSuperadmin,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [departmentFilter, setDepartmentFilter] = useState('ALL');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL');
|
||||||
|
|
||||||
|
const departments = Array.from(new Set(employees.map((e) => e.department))).filter(Boolean);
|
||||||
|
|
||||||
|
const filtered = employees.filter((emp) => {
|
||||||
|
const matchesSearch =
|
||||||
|
emp.full_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
emp.nik.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
emp.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
emp.position.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesDept = departmentFilter === 'ALL' || emp.department === departmentFilter;
|
||||||
|
const matchesStatus = statusFilter === 'ALL' || emp.status === statusFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesDept && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search & Filter Bar */}
|
||||||
|
<div className="p-4 bg-white rounded-2xl border border-slate-200/90 shadow-sm flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Cari nama, NIK, email, atau jabatan..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={departmentFilter}
|
||||||
|
onChange={(e) => setDepartmentFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs font-semibold bg-slate-50 border border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="ALL">Semua Departemen</option>
|
||||||
|
{departments.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{d}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 text-xs font-semibold bg-slate-50 border border-slate-200 rounded-xl text-slate-700 focus:outline-none focus:border-brand-primary"
|
||||||
|
>
|
||||||
|
<option value="ALL">Semua Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="probation">Probation</option>
|
||||||
|
<option value="resigned">Resigned</option>
|
||||||
|
<option value="terminated">Terminated</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Card */}
|
||||||
|
<div className="bg-white rounded-2xl border border-slate-200/90 shadow-sm overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-50/80 border-b border-slate-200/80 text-[11px] font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
<th className="py-3.5 px-4">Karyawan</th>
|
||||||
|
<th className="py-3.5 px-4">NIK & Jabatan</th>
|
||||||
|
<th className="py-3.5 px-4">Departemen</th>
|
||||||
|
<th className="py-3.5 px-4">Status & Mode</th>
|
||||||
|
<th className="py-3.5 px-4">Tanggal Masuk</th>
|
||||||
|
<th className="py-3.5 px-4 text-right">Aksi</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-sm">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-12 text-center text-slate-400">
|
||||||
|
Tidak ditemukan data karyawan yang sesuai.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filtered.map((emp) => (
|
||||||
|
<tr key={emp.id} className="hover:bg-slate-50/60 transition-colors">
|
||||||
|
{/* Employee Profile */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
src={emp.photo_url}
|
||||||
|
name={emp.full_name}
|
||||||
|
size="md"
|
||||||
|
status={emp.status}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/employees/${emp.id}`}
|
||||||
|
className="font-bold text-slate-900 hover:text-brand-primary transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<span>{emp.full_name}</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500 mt-0.5">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3 text-slate-400" /> {emp.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* NIK & Position */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<p className="font-semibold text-slate-800">{emp.position}</p>
|
||||||
|
<p className="text-xs font-mono text-slate-400">{emp.nik}</p>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Department */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-lg">
|
||||||
|
<Building2 className="w-3 h-3 text-slate-400" /> {emp.department}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status & Mode */}
|
||||||
|
<td className="py-3.5 px-4 space-y-1">
|
||||||
|
<div>
|
||||||
|
<Badge variant={emp.status as BadgeVariant} size="sm">
|
||||||
|
{emp.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-slate-500">
|
||||||
|
<MapPin className="w-2.5 h-2.5" /> {emp.work_location_default}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Join Date */}
|
||||||
|
<td className="py-3.5 px-4">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-slate-600 font-mono">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-400" />
|
||||||
|
<span>{emp.join_date || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="py-3.5 px-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<Link
|
||||||
|
href={`/employees/${emp.id}`}
|
||||||
|
className="p-2 text-slate-500 hover:text-brand-primary hover:bg-brand-light/50 rounded-xl transition-colors"
|
||||||
|
title="Lihat Detail & History Kontrak"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{isSuperadmin && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit(emp)}
|
||||||
|
className="p-2 text-slate-500 hover:text-amber-600 hover:bg-amber-50 rounded-xl transition-colors"
|
||||||
|
title="Edit Karyawan & Avatar"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(emp.id)}
|
||||||
|
className="p-2 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||||
|
title="Hapus Karyawan"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export const NavigationShell: React.FC<{ children: React.ReactNode }> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { loading } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-slate-50 gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-brand-primary text-white flex items-center justify-center font-black text-2xl animate-pulse shadow-brand-glow">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 font-semibold text-sm">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-brand-primary" />
|
||||||
|
<span>Loading Eigen HRIS...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-50/60 font-sans text-slate-900">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 overflow-x-hidden">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
CalendarCheck,
|
||||||
|
UserCheck,
|
||||||
|
Sparkles,
|
||||||
|
LogOut,
|
||||||
|
Shield,
|
||||||
|
Briefcase,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export const Sidebar: React.FC = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { user, employee, logout } = useAuth();
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
name: 'Dashboard & Gamification',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: LayoutDashboard,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Employee Hub & Contracts',
|
||||||
|
href: '/employees',
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Unified Attendances',
|
||||||
|
href: '/attendances',
|
||||||
|
icon: CalendarCheck,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'My Profile & Records',
|
||||||
|
href: '/profile',
|
||||||
|
icon: UserCheck,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-64 bg-white border-r border-slate-200/90 flex flex-col justify-between shrink-0 min-h-screen">
|
||||||
|
<div>
|
||||||
|
{/* Brand Header */}
|
||||||
|
<div className="h-18 px-6 flex items-center gap-3 border-b border-slate-100 py-5">
|
||||||
|
<div className="w-10 h-10 rounded-2xl bg-gradient-to-tr from-brand-primary via-brand-secondary to-brand-tertiary flex items-center justify-center text-white font-black text-xl shadow-brand-glow">
|
||||||
|
E
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-extrabold text-slate-900 tracking-tight text-lg">
|
||||||
|
Eigen<span className="text-brand-primary">HRIS</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||||
|
Modern People Ops
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Role Banner */}
|
||||||
|
<div className="mx-4 my-4 p-3 rounded-2xl bg-gradient-to-r from-brand-light/70 to-brand-pastel/30 border border-brand-pastel/60 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-xl bg-brand-primary text-white">
|
||||||
|
{user?.role === 'superadmin' ? (
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Briefcase className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-extrabold text-slate-900 uppercase tracking-wider">
|
||||||
|
{user?.role === 'superadmin' ? 'Superadmin Mode' : 'Staff Workspace'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 line-clamp-1">
|
||||||
|
{employee?.position || user?.username || 'Authenticated'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav className="px-3 space-y-1.5 mt-2">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href));
|
||||||
|
const Icon = item.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-150 group ${
|
||||||
|
isActive
|
||||||
|
? 'bg-brand-primary text-white shadow-sm shadow-brand-glow'
|
||||||
|
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Icon
|
||||||
|
className={`w-5 h-5 transition-transform group-hover:scale-105 ${
|
||||||
|
isActive ? 'text-white' : 'text-slate-400 group-hover:text-brand-primary'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.highlight && !isActive && (
|
||||||
|
<span className="p-1 rounded-full bg-brand-pastel/50 text-brand-dark">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User profile footer */}
|
||||||
|
<div className="p-4 border-t border-slate-100">
|
||||||
|
<div className="flex items-center justify-between p-2.5 rounded-2xl hover:bg-slate-50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
src={employee?.photo_url}
|
||||||
|
name={employee?.full_name || user?.username || 'User'}
|
||||||
|
size="md"
|
||||||
|
status={user?.role === 'superadmin' ? 'active' : 'present'}
|
||||||
|
/>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<p className="text-xs font-bold text-slate-900 truncate">
|
||||||
|
{employee?.full_name || user?.username || 'User'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400 truncate">
|
||||||
|
@{user?.username || 'staff'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
title="Logout"
|
||||||
|
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '@/context/AuthContext';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Clock, ShieldCheck, UserCheck, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TopHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopHeader: React.FC<TopHeaderProps> = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
action,
|
||||||
|
}) => {
|
||||||
|
const { user, employee, switchRole } = useAuth();
|
||||||
|
const [time, setTime] = useState<string>('');
|
||||||
|
const [dateStr, setDateStr] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
setTime(
|
||||||
|
now.toLocaleTimeString('id-ID', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
}) + ' WIB'
|
||||||
|
);
|
||||||
|
setDateStr(
|
||||||
|
now.toLocaleDateString('id-ID', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTime();
|
||||||
|
const interval = setInterval(updateTime, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 bg-white/85 backdrop-blur-md border-b border-slate-200/80 px-6 sm:px-8 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
{/* Title section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-xl sm:text-2xl font-extrabold text-slate-900 tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<Badge variant={user?.role === 'superadmin' ? 'brand' : 'wfo'} size="sm">
|
||||||
|
{user?.role === 'superadmin' ? 'Superadmin' : 'Staff'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-slate-500 font-medium mt-0.5">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right controls */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Live Clock Pill */}
|
||||||
|
<div className="hidden lg:flex items-center gap-2 px-3.5 py-1.5 rounded-xl bg-slate-50 border border-slate-200/80 text-xs font-mono text-slate-700">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-brand-primary" />
|
||||||
|
<span className="font-semibold text-slate-900">{time}</span>
|
||||||
|
<span className="text-slate-300">|</span>
|
||||||
|
<span className="text-slate-500">{dateStr}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Demo Fast Role Switcher */}
|
||||||
|
<div className="flex items-center bg-slate-100 p-1 rounded-xl border border-slate-200/80">
|
||||||
|
<button
|
||||||
|
onClick={() => switchRole('superadmin')}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
user?.role === 'superadmin'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>Admin</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => switchRole('staff')}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
user?.role === 'staff'
|
||||||
|
? 'bg-white text-brand-primary shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserCheck className="w-3.5 h-3.5" />
|
||||||
|
<span>Staff</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Action Button */}
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src?: string | null;
|
||||||
|
name: string;
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
status?: 'active' | 'probation' | 'resigned' | 'terminated' | 'present' | 'leave';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
src,
|
||||||
|
name,
|
||||||
|
size = 'md',
|
||||||
|
status,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
|
const getInitials = (n: string) => {
|
||||||
|
if (!n) return 'E';
|
||||||
|
const parts = n.trim().split(' ');
|
||||||
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
xs: 'w-6 h-6 text-[10px]',
|
||||||
|
sm: 'w-8 h-8 text-xs',
|
||||||
|
md: 'w-10 h-10 text-sm',
|
||||||
|
lg: 'w-12 h-12 text-base',
|
||||||
|
xl: 'w-16 h-16 text-lg font-bold',
|
||||||
|
'2xl': 'w-24 h-24 text-2xl font-bold',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors = {
|
||||||
|
active: 'bg-emerald-500 ring-2 ring-white',
|
||||||
|
present: 'bg-emerald-500 ring-2 ring-white',
|
||||||
|
probation: 'bg-amber-500 ring-2 ring-white',
|
||||||
|
leave: 'bg-purple-500 ring-2 ring-white',
|
||||||
|
resigned: 'bg-slate-400 ring-2 ring-white',
|
||||||
|
terminated: 'bg-red-500 ring-2 ring-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusDotSizes = {
|
||||||
|
xs: 'w-1.5 h-1.5 bottom-0 right-0',
|
||||||
|
sm: 'w-2 h-2 bottom-0 right-0',
|
||||||
|
md: 'w-2.5 h-2.5 bottom-0 right-0',
|
||||||
|
lg: 'w-3 h-3 bottom-0.5 right-0.5',
|
||||||
|
xl: 'w-3.5 h-3.5 bottom-1 right-1',
|
||||||
|
'2xl': 'w-5 h-5 bottom-1.5 right-1.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
'relative overflow-hidden rounded-2xl bg-gradient-to-br from-brand-light to-brand-pastel/40 border border-slate-200/80 flex items-center justify-center font-bold text-brand-primary select-none shadow-sm',
|
||||||
|
sizeStyles[size],
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{src && !imageError ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>{getInitials(name)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'absolute rounded-full',
|
||||||
|
statusColors[status],
|
||||||
|
statusDotSizes[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { Input } from './Input';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { AVATAR_STYLES, generateOnlineAvatarUrl } from '@/lib/avatar';
|
||||||
|
import { Sparkles, Shuffle, Check } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AvatarPickerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentUrl?: string;
|
||||||
|
employeeName: string;
|
||||||
|
onSelect: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarPickerModal: React.FC<AvatarPickerModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentUrl,
|
||||||
|
employeeName,
|
||||||
|
onSelect,
|
||||||
|
}) => {
|
||||||
|
const [selectedStyle, setSelectedStyle] = useState('notionists');
|
||||||
|
const [seed, setSeed] = useState(employeeName || 'EigenStaff');
|
||||||
|
const [previewUrl, setPreviewUrl] = useState(
|
||||||
|
currentUrl || generateOnlineAvatarUrl('notionists', employeeName || 'EigenStaff')
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStyleChange = (styleId: string) => {
|
||||||
|
setSelectedStyle(styleId);
|
||||||
|
const newUrl = generateOnlineAvatarUrl(styleId, seed);
|
||||||
|
setPreviewUrl(newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeedChange = (newSeed: string) => {
|
||||||
|
setSeed(newSeed);
|
||||||
|
const newUrl = generateOnlineAvatarUrl(selectedStyle, newSeed);
|
||||||
|
setPreviewUrl(newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRandomize = () => {
|
||||||
|
const randomKeywords = [
|
||||||
|
'Alpha', 'Phoenix', 'Nova', 'Cyber', 'Titan', 'Stella', 'Vibe', 'Rocket',
|
||||||
|
'Orion', 'Zephyr', 'Echo', 'Atlas', 'Cosmo', 'Sol', 'Luna', 'Vortex',
|
||||||
|
];
|
||||||
|
const randWord = randomKeywords[Math.floor(Math.random() * randomKeywords.length)];
|
||||||
|
const randNum = Math.floor(Math.random() * 1000);
|
||||||
|
const newSeed = `${employeeName || 'Staff'}-${randWord}-${randNum}`;
|
||||||
|
setSeed(newSeed);
|
||||||
|
const newUrl = generateOnlineAvatarUrl(selectedStyle, newSeed);
|
||||||
|
setPreviewUrl(newUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onSelect(previewUrl);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Online Avatar & Portrait Generator"
|
||||||
|
subtitle="Select a dynamic online avatar style or generate random portrait URLs"
|
||||||
|
maxWidth="xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Preview Header */}
|
||||||
|
<div className="p-6 rounded-2xl bg-gradient-to-br from-brand-light via-white to-brand-pastel/30 border border-brand-pastel/60 flex flex-col sm:flex-row items-center gap-6 justify-between shadow-sm">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<Avatar src={previewUrl} name={seed} size="2xl" status="active" />
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-brand-primary text-white text-[11px] font-bold uppercase tracking-wider">
|
||||||
|
<Sparkles className="w-3 h-3" /> Live Online Preview
|
||||||
|
</div>
|
||||||
|
<h4 className="text-base font-bold text-slate-900 mt-1">
|
||||||
|
{employeeName || 'Employee Name'}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 font-mono break-all line-clamp-1 max-w-xs mt-0.5">
|
||||||
|
{previewUrl}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="pastel"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRandomize}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Shuffle className="w-4 h-4" /> Randomize Look
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seed Input */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
label="Avatar Seed / Character Keyword"
|
||||||
|
value={seed}
|
||||||
|
onChange={(e) => handleSeedChange(e.target.value)}
|
||||||
|
placeholder="Type any word, name, or key to customize appearance..."
|
||||||
|
helperText="Changing the seed alters the hair, facial features, accessories, or photo ID."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style Selector Grid */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-700 tracking-wide mb-2.5">
|
||||||
|
Choose Portrait Style & Provider
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{AVATAR_STYLES.map((style) => {
|
||||||
|
const sampleUrl = generateOnlineAvatarUrl(style.id, seed);
|
||||||
|
const isSelected = selectedStyle === style.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={style.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStyleChange(style.id)}
|
||||||
|
className={`p-3 rounded-2xl border text-left transition-all duration-150 flex flex-col items-center gap-2 group ${
|
||||||
|
isSelected
|
||||||
|
? 'border-brand-primary bg-brand-light/50 ring-2 ring-brand-primary/20 shadow-sm'
|
||||||
|
: 'border-slate-200 hover:border-brand-tertiary bg-white hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Avatar src={sampleUrl} name={style.label} size="lg" />
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold text-center leading-tight ${
|
||||||
|
isSelected ? 'text-brand-primary' : 'text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-100">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="primary" onClick={handleConfirm}>
|
||||||
|
<Check className="w-4 h-4" /> Apply Photo URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export type BadgeVariant =
|
||||||
|
| 'present'
|
||||||
|
| 'wfo'
|
||||||
|
| 'wfh'
|
||||||
|
| 'late'
|
||||||
|
| 'leave'
|
||||||
|
| 'sick'
|
||||||
|
| 'travel'
|
||||||
|
| 'pending'
|
||||||
|
| 'active'
|
||||||
|
| 'probation'
|
||||||
|
| 'resigned'
|
||||||
|
| 'terminated'
|
||||||
|
| 'brand'
|
||||||
|
| 'streak'
|
||||||
|
| 'neutral';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
variant?: BadgeVariant;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Badge: React.FC<BadgeProps> = ({
|
||||||
|
variant = 'neutral',
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
size = 'md',
|
||||||
|
}) => {
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'px-2 py-0.5 text-[10px]',
|
||||||
|
md: 'px-2.5 py-1 text-xs',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles: Record<BadgeVariant, string> = {
|
||||||
|
present: 'bg-emerald-50 text-emerald-700 border border-emerald-200/80 font-semibold',
|
||||||
|
wfo: 'bg-blue-50 text-blue-700 border border-blue-200 font-semibold',
|
||||||
|
wfh: 'bg-indigo-50 text-indigo-700 border border-indigo-200 font-semibold',
|
||||||
|
late: 'bg-amber-50 text-amber-800 border border-amber-200 font-semibold',
|
||||||
|
leave: 'bg-purple-50 text-purple-700 border border-purple-200 font-semibold',
|
||||||
|
sick: 'bg-rose-50 text-rose-700 border border-rose-200 font-semibold',
|
||||||
|
travel: 'bg-cyan-50 text-cyan-700 border border-cyan-200 font-semibold',
|
||||||
|
pending: 'bg-yellow-50 text-yellow-800 border border-yellow-200 font-semibold animate-pulse',
|
||||||
|
active: 'bg-emerald-50 text-emerald-700 border border-emerald-200 font-semibold',
|
||||||
|
probation: 'bg-amber-50 text-amber-800 border border-amber-200 font-semibold',
|
||||||
|
resigned: 'bg-slate-100 text-slate-600 border border-slate-200 font-medium',
|
||||||
|
terminated: 'bg-red-50 text-red-700 border border-red-200 font-medium',
|
||||||
|
brand: 'bg-brand-primary/10 text-brand-primary border border-brand-primary/20 font-bold',
|
||||||
|
streak: 'bg-gradient-to-r from-orange-500 to-amber-500 text-white font-bold shadow-sm',
|
||||||
|
neutral: 'bg-slate-100 text-slate-700 border border-slate-200/80 font-medium',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
'inline-flex items-center gap-1 rounded-full uppercase tracking-wider',
|
||||||
|
sizeStyles[size],
|
||||||
|
variantStyles[variant],
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'pastel';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button: React.FC<ButtonProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseStyles =
|
||||||
|
'inline-flex items-center justify-center font-semibold rounded-xl transition-all duration-150 active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs gap-1.5',
|
||||||
|
md: 'px-4 py-2 text-sm gap-2',
|
||||||
|
lg: 'px-6 py-2.5 text-base gap-2.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
primary:
|
||||||
|
'bg-brand-primary text-white hover:bg-brand-hover shadow-sm hover:shadow-brand-glow focus:ring-brand-primary',
|
||||||
|
secondary:
|
||||||
|
'bg-brand-secondary text-white hover:bg-brand-hover focus:ring-brand-secondary',
|
||||||
|
pastel:
|
||||||
|
'bg-brand-pastel/30 text-brand-dark hover:bg-brand-pastel/60 border border-brand-pastel focus:ring-brand-pastel',
|
||||||
|
outline:
|
||||||
|
'border border-slate-200 bg-white text-slate-700 hover:bg-slate-50 hover:border-slate-300 focus:ring-slate-300',
|
||||||
|
ghost:
|
||||||
|
'bg-transparent text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-slate-200',
|
||||||
|
danger:
|
||||||
|
'bg-red-600 text-white hover:bg-red-700 shadow-sm focus:ring-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
baseStyles,
|
||||||
|
sizeStyles[size],
|
||||||
|
variantStyles[variant],
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && (
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'default' | 'glow' | 'accent' | 'pastel';
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Card: React.FC<CardProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = 'default',
|
||||||
|
interactive = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const variantStyles = {
|
||||||
|
default: 'bg-white border border-slate-200/90 shadow-sm',
|
||||||
|
glow: 'bg-white border border-brand-pastel/80 shadow-glow',
|
||||||
|
accent: 'bg-gradient-to-br from-white to-brand-light/30 border border-brand-tertiary/40 shadow-card',
|
||||||
|
pastel: 'bg-brand-pastel/15 border border-brand-pastel/60 shadow-sm',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
'rounded-2xl p-5 transition-all duration-200',
|
||||||
|
variantStyles[variant],
|
||||||
|
interactive && 'hover:shadow-card-hover hover:border-brand-tertiary cursor-pointer',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, helperText, icon, className, id, ...props }, ref) => {
|
||||||
|
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-1.5">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-xs font-semibold text-slate-700 tracking-wide"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="relative">
|
||||||
|
{icon && (
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none text-slate-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
ref={ref}
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
'w-full px-3.5 py-2 text-sm text-slate-900 bg-white border rounded-xl transition-all duration-150',
|
||||||
|
'placeholder:text-slate-400 placeholder:text-xs',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary',
|
||||||
|
icon && 'pl-10',
|
||||||
|
error
|
||||||
|
? 'border-red-400 focus:border-red-500 focus:ring-red-200'
|
||||||
|
: 'border-slate-200 hover:border-slate-300',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-500 font-medium">{error}</p>}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="text-xs text-slate-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Modal: React.FC<ModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
maxWidth = 'lg',
|
||||||
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const maxWidthStyles = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal Dialog */}
|
||||||
|
<div
|
||||||
|
className={`relative w-full ${maxWidthStyles[maxWidth]} bg-white rounded-3xl shadow-2xl border border-slate-100 p-6 sm:p-7 z-10 animate-in fade-in zoom-in-95 duration-200`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between pb-4 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-900 tracking-tight">{title}</h3>
|
||||||
|
{subtitle && <p className="text-xs text-slate-500 mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, options, className, id, ...props }, ref) => {
|
||||||
|
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-1.5">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={selectId}
|
||||||
|
className="block text-xs font-semibold text-slate-700 tracking-wide"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
ref={ref}
|
||||||
|
className={twMerge(
|
||||||
|
clsx(
|
||||||
|
'w-full px-3.5 py-2 text-sm text-slate-900 bg-white border rounded-xl transition-all duration-150',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary',
|
||||||
|
error
|
||||||
|
? 'border-red-400 focus:border-red-500 focus:ring-red-200'
|
||||||
|
: 'border-slate-200 hover:border-slate-300',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-xs text-red-500 font-medium">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = 'Select';
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { User, AuthSession } from '@/types/auth';
|
||||||
|
import { Employee } from '@/types/employee';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
employee: Employee | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password?: string) => Promise<boolean>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
switchRole: (role: 'superadmin' | 'staff', employeeId?: string) => Promise<void>;
|
||||||
|
refreshSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refreshSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.session?.user) {
|
||||||
|
setUser(data.session.user);
|
||||||
|
setEmployee(data.session.employee || null);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
setEmployee(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load session:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshSession();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password = 'password'): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.session) {
|
||||||
|
setUser(data.session.user);
|
||||||
|
setEmployee(data.session.employee || null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Login error:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
setUser(null);
|
||||||
|
setEmployee(null);
|
||||||
|
window.location.href = '/login';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Logout error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchRole = async (role: 'superadmin' | 'staff', employeeId?: string) => {
|
||||||
|
const targetUsername = role === 'superadmin' ? 'superadmin' : 'budi.santoso';
|
||||||
|
await login(targetUsername, role === 'superadmin' ? 'admin123' : 'staff123');
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
user,
|
||||||
|
employee,
|
||||||
|
loading,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
switchRole,
|
||||||
|
refreshSession,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { Employee } from '@/types/employee';
|
||||||
|
import { Attendance } from '@/types/attendance';
|
||||||
|
import {
|
||||||
|
DashboardAnalytics,
|
||||||
|
LeaderboardUser,
|
||||||
|
EarlyBirdHighlight,
|
||||||
|
LatecomerHighlight,
|
||||||
|
NightOwlHighlight,
|
||||||
|
DailyStatusCount,
|
||||||
|
} from '@/types/dashboard';
|
||||||
|
|
||||||
|
// Helper to convert HH:mm:ss into seconds from midnight
|
||||||
|
function timeToSeconds(timeStr: string | null): number | null {
|
||||||
|
if (!timeStr) return null;
|
||||||
|
const parts = timeStr.split(':').map(Number);
|
||||||
|
if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) return null;
|
||||||
|
const h = parts[0];
|
||||||
|
const m = parts[1];
|
||||||
|
const s = parts[2] || 0;
|
||||||
|
return h * 3600 + m * 60 + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to convert seconds from midnight back to HH:mm
|
||||||
|
function secondsToTimeString(totalSeconds: number): string {
|
||||||
|
const h = Math.floor(totalSeconds / 3600) % 24;
|
||||||
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeDashboardAnalytics(
|
||||||
|
employees: Employee[],
|
||||||
|
attendances: Attendance[],
|
||||||
|
dateRange: { from: string; to: string; preset?: string }
|
||||||
|
): DashboardAnalytics {
|
||||||
|
const activeEmployees = employees.filter((e) => e.status !== 'terminated' && e.status !== 'resigned');
|
||||||
|
const employeeMap = new Map<string, Employee>();
|
||||||
|
employees.forEach((e) => employeeMap.set(e.id, e));
|
||||||
|
|
||||||
|
// Filter attendances in the selected date range
|
||||||
|
const filteredAttendances = attendances.filter((a) => {
|
||||||
|
return a.date >= dateRange.from && a.date <= dateRange.to;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group attendances by employee
|
||||||
|
const empRecords = new Map<string, Attendance[]>();
|
||||||
|
filteredAttendances.forEach((a) => {
|
||||||
|
const list = empRecords.get(a.employee_id) || [];
|
||||||
|
list.push(a);
|
||||||
|
empRecords.set(a.employee_id, list);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Calculate Leaderboard "Si Paling Rajin"
|
||||||
|
const leaderboardCandidates: LeaderboardUser[] = activeEmployees.map((emp) => {
|
||||||
|
const records = empRecords.get(emp.id) || [];
|
||||||
|
const presentRecords = records.filter(
|
||||||
|
(r) => r.type === 'PRESENT' && (r.status === 'APPROVED' || r.status === 'CONFIRMED')
|
||||||
|
);
|
||||||
|
const onTimeRecords = presentRecords.filter((r) => (r.late_minutes || 0) === 0);
|
||||||
|
const totalLateMinutes = records.reduce((acc, r) => acc + (r.late_minutes || 0), 0);
|
||||||
|
|
||||||
|
// Calculate longest consecutive attendance streak in sorted date order
|
||||||
|
const sortedDates = Array.from(new Set(presentRecords.map((r) => r.date))).sort();
|
||||||
|
let currentStreak = 0;
|
||||||
|
let maxStreak = 0;
|
||||||
|
for (let i = 0; i < sortedDates.length; i++) {
|
||||||
|
if (i === 0) {
|
||||||
|
currentStreak = 1;
|
||||||
|
} else {
|
||||||
|
const prev = new Date(sortedDates[i - 1]);
|
||||||
|
const curr = new Date(sortedDates[i]);
|
||||||
|
const diffDays = Math.round((curr.getTime() - prev.getTime()) / (1000 * 3600 * 24));
|
||||||
|
if (diffDays === 1 || diffDays === 3) {
|
||||||
|
// allow weekend skip
|
||||||
|
currentStreak++;
|
||||||
|
} else {
|
||||||
|
currentStreak = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxStreak = Math.max(maxStreak, currentStreak);
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = onTimeRecords.length * 10 + (presentRecords.length - onTimeRecords.length) * 5 - totalLateMinutes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
employee: emp,
|
||||||
|
score: Math.max(0, score),
|
||||||
|
presentCount: presentRecords.length,
|
||||||
|
onTimeCount: onTimeRecords.length,
|
||||||
|
streakCount: maxStreak,
|
||||||
|
rank: 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort descending by score, then onTimeCount, then streakCount
|
||||||
|
leaderboardCandidates.sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
if (b.onTimeCount !== a.onTimeCount) return b.onTimeCount - a.onTimeCount;
|
||||||
|
return b.streakCount - a.streakCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
const leaderboard: LeaderboardUser[] = leaderboardCandidates.slice(0, 5).map((u, idx) => ({
|
||||||
|
...u,
|
||||||
|
rank: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 2. Early Bird "Si Paling Pagi"
|
||||||
|
let earlyBird: EarlyBirdHighlight | null = null;
|
||||||
|
let minAvgClockInSeconds = Infinity;
|
||||||
|
|
||||||
|
activeEmployees.forEach((emp) => {
|
||||||
|
const records = (empRecords.get(emp.id) || []).filter(
|
||||||
|
(r) => (r.type === 'PRESENT' || r.type === 'LATE_PERMIT') && r.clock_in
|
||||||
|
);
|
||||||
|
if (records.length === 0) return;
|
||||||
|
|
||||||
|
let totalSeconds = 0;
|
||||||
|
let earliestSec = Infinity;
|
||||||
|
let earliestStr = '';
|
||||||
|
|
||||||
|
records.forEach((r) => {
|
||||||
|
const sec = timeToSeconds(r.clock_in);
|
||||||
|
if (sec !== null) {
|
||||||
|
totalSeconds += sec;
|
||||||
|
if (sec < earliestSec) {
|
||||||
|
earliestSec = sec;
|
||||||
|
earliestStr = r.clock_in!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgSeconds = totalSeconds / records.length;
|
||||||
|
if (avgSeconds < minAvgClockInSeconds) {
|
||||||
|
minAvgClockInSeconds = avgSeconds;
|
||||||
|
earlyBird = {
|
||||||
|
employee: emp,
|
||||||
|
averageClockIn: secondsToTimeString(avgSeconds),
|
||||||
|
earliestClockIn: earliestStr.slice(0, 5),
|
||||||
|
count: records.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Chronic Snoozer "Si Paling Telat"
|
||||||
|
let latecomer: LatecomerHighlight | null = null;
|
||||||
|
let maxLateMinutes = 0;
|
||||||
|
|
||||||
|
activeEmployees.forEach((emp) => {
|
||||||
|
const records = empRecords.get(emp.id) || [];
|
||||||
|
const totalLate = records.reduce((acc, r) => acc + (r.late_minutes || 0), 0);
|
||||||
|
const lateEntries = records.filter((r) => (r.late_minutes || 0) > 0);
|
||||||
|
const maxSingleLate = Math.max(...records.map((r) => r.late_minutes || 0), 0);
|
||||||
|
|
||||||
|
if (totalLate > maxLateMinutes) {
|
||||||
|
maxLateMinutes = totalLate;
|
||||||
|
latecomer = {
|
||||||
|
employee: emp,
|
||||||
|
totalLateMinutes: totalLate,
|
||||||
|
lateCount: lateEntries.length,
|
||||||
|
latestLateMinutes: maxSingleLate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Night Owl "Si Paling Pulang Malam"
|
||||||
|
let nightOwl: NightOwlHighlight | null = null;
|
||||||
|
let maxAvgClockOutSeconds = 0;
|
||||||
|
|
||||||
|
activeEmployees.forEach((emp) => {
|
||||||
|
const records = (empRecords.get(emp.id) || []).filter((r) => r.clock_out);
|
||||||
|
if (records.length === 0) return;
|
||||||
|
|
||||||
|
let totalSeconds = 0;
|
||||||
|
let latestSec = 0;
|
||||||
|
let latestStr = '';
|
||||||
|
|
||||||
|
records.forEach((r) => {
|
||||||
|
const sec = timeToSeconds(r.clock_out);
|
||||||
|
if (sec !== null) {
|
||||||
|
totalSeconds += sec;
|
||||||
|
if (sec > latestSec) {
|
||||||
|
latestSec = sec;
|
||||||
|
latestStr = r.clock_out!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgSeconds = totalSeconds / records.length;
|
||||||
|
if (avgSeconds > maxAvgClockOutSeconds) {
|
||||||
|
maxAvgClockOutSeconds = avgSeconds;
|
||||||
|
nightOwl = {
|
||||||
|
employee: emp,
|
||||||
|
averageClockOut: secondsToTimeString(avgSeconds),
|
||||||
|
latestClockOut: latestStr.slice(0, 5),
|
||||||
|
count: records.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Daily Status Feeds (Today, Yesterday, Tomorrow)
|
||||||
|
const baseDate = new Date();
|
||||||
|
const todayStr = baseDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const yesterdayDate = new Date(baseDate);
|
||||||
|
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||||
|
const yesterdayStr = yesterdayDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const tomorrowDate = new Date(baseDate);
|
||||||
|
tomorrowDate.setDate(tomorrowDate.getDate() + 1);
|
||||||
|
const tomorrowStr = tomorrowDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function computeDayStatus(dateStr: string): DailyStatusCount {
|
||||||
|
const dayRecords = attendances.filter((a) => a.date === dateStr);
|
||||||
|
const presentList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
const leaveList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
const sickList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
const lateList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
const travelList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
const pendingList: { employee: Employee; record: Attendance }[] = [];
|
||||||
|
|
||||||
|
const recordedEmpIds = new Set<string>();
|
||||||
|
|
||||||
|
dayRecords.forEach((rec) => {
|
||||||
|
const emp = employeeMap.get(rec.employee_id);
|
||||||
|
if (!emp) return;
|
||||||
|
recordedEmpIds.add(emp.id);
|
||||||
|
|
||||||
|
if (rec.status === 'PENDING') {
|
||||||
|
pendingList.push({ employee: emp, record: rec });
|
||||||
|
} else if (rec.type === 'PRESENT') {
|
||||||
|
presentList.push({ employee: emp, record: rec });
|
||||||
|
if ((rec.late_minutes || 0) > 0) {
|
||||||
|
lateList.push({ employee: emp, record: rec });
|
||||||
|
}
|
||||||
|
} else if (rec.type === 'ANNUAL_LEAVE' || rec.type === 'PERMIT' || rec.type === 'EARLY_LEAVE_PERMIT') {
|
||||||
|
leaveList.push({ employee: emp, record: rec });
|
||||||
|
} else if (rec.type === 'SICK') {
|
||||||
|
sickList.push({ employee: emp, record: rec });
|
||||||
|
} else if (rec.type === 'LATE_PERMIT') {
|
||||||
|
lateList.push({ employee: emp, record: rec });
|
||||||
|
} else if (rec.type === 'OFFICIAL_TRAVEL') {
|
||||||
|
travelList.push({ employee: emp, record: rec });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const unaccounted = activeEmployees.filter((e) => !recordedEmpIds.has(e.id));
|
||||||
|
const wfoCount = presentList.filter((p) => p.record.work_mode === 'WFO').length;
|
||||||
|
const wfhCount = presentList.filter((p) => p.record.work_mode === 'WFH').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: dateStr,
|
||||||
|
totalEmployees: activeEmployees.length,
|
||||||
|
presentCount: presentList.length,
|
||||||
|
wfoCount,
|
||||||
|
wfhCount,
|
||||||
|
leaveCount: leaveList.length,
|
||||||
|
sickCount: sickList.length,
|
||||||
|
lateCount: lateList.length,
|
||||||
|
officialTravelCount: travelList.length,
|
||||||
|
pendingCount: pendingList.length,
|
||||||
|
employees: {
|
||||||
|
present: presentList,
|
||||||
|
leave: leaveList,
|
||||||
|
sick: sickList,
|
||||||
|
late: lateList,
|
||||||
|
travel: travelList,
|
||||||
|
pending: pendingList,
|
||||||
|
unaccounted,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateRange,
|
||||||
|
leaderboard,
|
||||||
|
earlyBird,
|
||||||
|
latecomer,
|
||||||
|
nightOwl,
|
||||||
|
todayStatus: computeDayStatus(todayStr),
|
||||||
|
yesterdayStatus: computeDayStatus(yesterdayStr),
|
||||||
|
tomorrowStatus: computeDayStatus(tomorrowStr),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
export interface AvatarOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
source: 'dicebear' | 'pravatar' | 'unsplash' | 'ui-avatars';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_STYLES = [
|
||||||
|
{ id: 'notionists', label: 'Notionist Illustration' },
|
||||||
|
{ id: 'lorelei', label: 'Lorelei Modern' },
|
||||||
|
{ id: 'avataaars', label: 'Avataaars Character' },
|
||||||
|
{ id: 'micah', label: 'Micah Minimal' },
|
||||||
|
{ id: 'bottts', label: 'Robo Bottts' },
|
||||||
|
{ id: 'pravatar', label: 'Real Portrait (Pravatar)' },
|
||||||
|
{ id: 'unsplash', label: 'Studio Headshot (Unsplash)' },
|
||||||
|
{ id: 'ui-avatars', label: 'Clean Initials' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function generateOnlineAvatarUrl(
|
||||||
|
style: string,
|
||||||
|
seed: string,
|
||||||
|
backgroundColor: string = 'ebf2ff'
|
||||||
|
): string {
|
||||||
|
const cleanSeed = encodeURIComponent(seed.trim() || 'EigenStaff');
|
||||||
|
|
||||||
|
switch (style) {
|
||||||
|
case 'notionists':
|
||||||
|
case 'lorelei':
|
||||||
|
case 'avataaars':
|
||||||
|
case 'micah':
|
||||||
|
case 'bottts':
|
||||||
|
return `https://api.dicebear.com/7.x/${style}/svg?seed=${cleanSeed}&backgroundColor=${backgroundColor}`;
|
||||||
|
case 'pravatar':
|
||||||
|
// generates consistent portrait per seed
|
||||||
|
const hash = Math.abs(
|
||||||
|
cleanSeed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 70
|
||||||
|
);
|
||||||
|
return `https://i.pravatar.cc/300?img=${hash + 1}`;
|
||||||
|
case 'unsplash':
|
||||||
|
// Curated verified portraits for diverse professionals
|
||||||
|
const portraits = [
|
||||||
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
'https://images.unsplash.com/photo-1580489944761-15a19d654956?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
'https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=300&h=300&fit=crop&crop=faces',
|
||||||
|
];
|
||||||
|
const portraitIndex = Math.abs(
|
||||||
|
cleanSeed.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % portraits.length
|
||||||
|
);
|
||||||
|
return portraits[portraitIndex];
|
||||||
|
case 'ui-avatars':
|
||||||
|
default:
|
||||||
|
return `https://ui-avatars.com/api/?name=${cleanSeed}&background=1b4ef5&color=ffffff&bold=true&rounded=true`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRandomAvatarPreset(name: string): string {
|
||||||
|
const styles = ['notionists', 'lorelei', 'pravatar', 'unsplash'];
|
||||||
|
const randomStyle = styles[Math.floor(Math.random() * styles.length)];
|
||||||
|
const randomSeed = `${name}-${Math.floor(Math.random() * 1000)}`;
|
||||||
|
return generateOnlineAvatarUrl(randomStyle, randomSeed);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import { User } from '@/types/auth';
|
||||||
|
import { Employee, EmployeeInput } from '@/types/employee';
|
||||||
|
import { EmployeeContract, ContractInput } from '@/types/contract';
|
||||||
|
import { Attendance, AttendanceInput } from '@/types/attendance';
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||||
|
|
||||||
|
const USERS_FILE = path.join(DATA_DIR, 'users.csv');
|
||||||
|
const EMPLOYEES_FILE = path.join(DATA_DIR, 'employees.csv');
|
||||||
|
const CONTRACTS_FILE = path.join(DATA_DIR, 'employee_contracts.csv');
|
||||||
|
const ATTENDANCES_FILE = path.join(DATA_DIR, 'attendances.csv');
|
||||||
|
|
||||||
|
// Helper to safely read and parse CSV
|
||||||
|
async function readCsv<T>(filePath: string): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8');
|
||||||
|
const result = Papa.parse<T>(content, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
dynamicTyping: true,
|
||||||
|
});
|
||||||
|
return result.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.error(`Error reading CSV ${filePath}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to safely write data to CSV atomically
|
||||||
|
async function writeCsv<T>(filePath: string, data: T[]): Promise<void> {
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
const csvString = Papa.unparse(data, {
|
||||||
|
quotes: false,
|
||||||
|
header: true,
|
||||||
|
});
|
||||||
|
const tempPath = `${filePath}.tmp.${Date.now()}`;
|
||||||
|
await fs.writeFile(tempPath, csvString, 'utf-8');
|
||||||
|
await fs.rename(tempPath, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= USER OPERATIONS =================
|
||||||
|
export async function getUsers(): Promise<User[]> {
|
||||||
|
const users = await readCsv<User>(USERS_FILE);
|
||||||
|
return users.map((u) => ({
|
||||||
|
...u,
|
||||||
|
role: u.role || 'staff',
|
||||||
|
employee_id: u.employee_id ? String(u.employee_id) : '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByUsername(username: string): Promise<User | null> {
|
||||||
|
const users = await getUsers();
|
||||||
|
return users.find((u) => u.username.toLowerCase() === username.trim().toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string): Promise<User | null> {
|
||||||
|
const users = await getUsers();
|
||||||
|
return users.find((u) => u.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= EMPLOYEE OPERATIONS =================
|
||||||
|
export async function getEmployees(): Promise<Employee[]> {
|
||||||
|
const raw = await readCsv<any>(EMPLOYEES_FILE);
|
||||||
|
return raw.map((e) => ({
|
||||||
|
id: String(e.id || ''),
|
||||||
|
nik: String(e.nik || ''),
|
||||||
|
full_name: String(e.full_name || ''),
|
||||||
|
email: String(e.email || ''),
|
||||||
|
phone: String(e.phone || ''),
|
||||||
|
department: String(e.department || ''),
|
||||||
|
position: String(e.position || ''),
|
||||||
|
status: e.status || 'active',
|
||||||
|
join_date: String(e.join_date || ''),
|
||||||
|
work_location_default: e.work_location_default || 'WFO',
|
||||||
|
photo_url: String(e.photo_url || ''),
|
||||||
|
created_at: String(e.created_at || new Date().toISOString()),
|
||||||
|
updated_at: String(e.updated_at || new Date().toISOString()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmployeeById(id: string): Promise<Employee | null> {
|
||||||
|
const employees = await getEmployees();
|
||||||
|
return employees.find((e) => e.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEmployee(input: EmployeeInput): Promise<Employee> {
|
||||||
|
const employees = await getEmployees();
|
||||||
|
const nextNum = employees.length + 1;
|
||||||
|
const newId = input.id || `EMP-${String(nextNum).padStart(3, '0')}`;
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const newEmployee: Employee = {
|
||||||
|
id: newId,
|
||||||
|
nik: input.nik,
|
||||||
|
full_name: input.full_name,
|
||||||
|
email: input.email,
|
||||||
|
phone: input.phone,
|
||||||
|
department: input.department,
|
||||||
|
position: input.position,
|
||||||
|
status: input.status,
|
||||||
|
join_date: input.join_date,
|
||||||
|
work_location_default: input.work_location_default,
|
||||||
|
photo_url: input.photo_url || `https://api.dicebear.com/7.x/notionists/svg?seed=${encodeURIComponent(input.full_name)}&backgroundColor=ebf2ff`,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
employees.push(newEmployee);
|
||||||
|
await writeCsv(EMPLOYEES_FILE, employees);
|
||||||
|
return newEmployee;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEmployee(id: string, input: Partial<EmployeeInput>): Promise<Employee | null> {
|
||||||
|
const employees = await getEmployees();
|
||||||
|
const index = employees.findIndex((e) => e.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
const existing = employees[index];
|
||||||
|
const updated: Employee = {
|
||||||
|
...existing,
|
||||||
|
...input,
|
||||||
|
id: existing.id,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
employees[index] = updated;
|
||||||
|
await writeCsv(EMPLOYEES_FILE, employees);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEmployee(id: string): Promise<boolean> {
|
||||||
|
const employees = await getEmployees();
|
||||||
|
const filtered = employees.filter((e) => e.id !== id);
|
||||||
|
if (filtered.length === employees.length) return false;
|
||||||
|
await writeCsv(EMPLOYEES_FILE, filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= CONTRACT OPERATIONS =================
|
||||||
|
export async function getContracts(): Promise<EmployeeContract[]> {
|
||||||
|
const raw = await readCsv<any>(CONTRACTS_FILE);
|
||||||
|
return raw.map((c) => ({
|
||||||
|
id: String(c.id || ''),
|
||||||
|
employee_id: String(c.employee_id || ''),
|
||||||
|
contract_number: String(c.contract_number || ''),
|
||||||
|
contract_type: c.contract_type || 'PKWT',
|
||||||
|
start_date: String(c.start_date || ''),
|
||||||
|
end_date: c.end_date ? String(c.end_date) : null,
|
||||||
|
salary: Number(c.salary) || 0,
|
||||||
|
notes: String(c.notes || ''),
|
||||||
|
document_url: c.document_url ? String(c.document_url) : null,
|
||||||
|
created_at: String(c.created_at || new Date().toISOString()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContractsByEmployeeId(employeeId: string): Promise<EmployeeContract[]> {
|
||||||
|
const contracts = await getContracts();
|
||||||
|
return contracts
|
||||||
|
.filter((c) => c.employee_id === employeeId)
|
||||||
|
.sort((a, b) => new Date(a.start_date).getTime() - new Date(b.start_date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContract(input: ContractInput): Promise<EmployeeContract> {
|
||||||
|
const contracts = await getContracts();
|
||||||
|
const nextNum = contracts.length + 1;
|
||||||
|
const newId = `CTR-${String(nextNum).padStart(3, '0')}`;
|
||||||
|
|
||||||
|
const newContract: EmployeeContract = {
|
||||||
|
id: newId,
|
||||||
|
employee_id: input.employee_id,
|
||||||
|
contract_number: input.contract_number,
|
||||||
|
contract_type: input.contract_type,
|
||||||
|
start_date: input.start_date,
|
||||||
|
end_date: input.end_date || null,
|
||||||
|
salary: input.salary,
|
||||||
|
notes: input.notes || '',
|
||||||
|
document_url: input.document_url || null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
contracts.push(newContract);
|
||||||
|
await writeCsv(CONTRACTS_FILE, contracts);
|
||||||
|
return newContract;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContract(id: string): Promise<boolean> {
|
||||||
|
const contracts = await getContracts();
|
||||||
|
const filtered = contracts.filter((c) => c.id !== id);
|
||||||
|
if (filtered.length === contracts.length) return false;
|
||||||
|
await writeCsv(CONTRACTS_FILE, filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= ATTENDANCE & INCIDENT OPERATIONS =================
|
||||||
|
export async function getAttendances(): Promise<Attendance[]> {
|
||||||
|
const raw = await readCsv<any>(ATTENDANCES_FILE);
|
||||||
|
return raw.map((a) => ({
|
||||||
|
id: String(a.id || ''),
|
||||||
|
employee_id: String(a.employee_id || ''),
|
||||||
|
date: String(a.date || ''),
|
||||||
|
type: a.type || 'PRESENT',
|
||||||
|
work_mode: a.work_mode || 'WFO',
|
||||||
|
clock_in: a.clock_in ? String(a.clock_in) : null,
|
||||||
|
clock_out: a.clock_out ? String(a.clock_out) : null,
|
||||||
|
duration_minutes: a.duration_minutes !== undefined && a.duration_minutes !== null ? Number(a.duration_minutes) : null,
|
||||||
|
late_minutes: Number(a.late_minutes) || 0,
|
||||||
|
status: a.status || 'APPROVED',
|
||||||
|
reason_or_notes: String(a.reason_or_notes || ''),
|
||||||
|
attachment_url: a.attachment_url ? String(a.attachment_url) : null,
|
||||||
|
created_at: String(a.created_at || new Date().toISOString()),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAttendancesByEmployeeId(employeeId: string): Promise<Attendance[]> {
|
||||||
|
const attendances = await getAttendances();
|
||||||
|
return attendances
|
||||||
|
.filter((a) => a.employee_id === employeeId)
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAttendance(input: AttendanceInput): Promise<Attendance> {
|
||||||
|
const attendances = await getAttendances();
|
||||||
|
const dateFormatted = (input.date || new Date().toISOString().slice(0, 10)).replace(/-/g, '');
|
||||||
|
const randSuffix = Math.floor(100 + Math.random() * 900);
|
||||||
|
const newId = `ATT-${dateFormatted}-${randSuffix}`;
|
||||||
|
|
||||||
|
const newRecord: Attendance = {
|
||||||
|
id: newId,
|
||||||
|
employee_id: input.employee_id,
|
||||||
|
date: input.date,
|
||||||
|
type: input.type,
|
||||||
|
work_mode: input.work_mode,
|
||||||
|
clock_in: input.clock_in || null,
|
||||||
|
clock_out: input.clock_out || null,
|
||||||
|
duration_minutes: input.duration_minutes !== undefined ? input.duration_minutes : null,
|
||||||
|
late_minutes: input.late_minutes || 0,
|
||||||
|
status: input.status || 'PENDING',
|
||||||
|
reason_or_notes: input.reason_or_notes || '',
|
||||||
|
attachment_url: input.attachment_url || null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
attendances.push(newRecord);
|
||||||
|
await writeCsv(ATTENDANCES_FILE, attendances);
|
||||||
|
return newRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAttendance(id: string, input: Partial<AttendanceInput>): Promise<Attendance | null> {
|
||||||
|
const attendances = await getAttendances();
|
||||||
|
const index = attendances.findIndex((a) => a.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
|
||||||
|
const existing = attendances[index];
|
||||||
|
const updated: Attendance = {
|
||||||
|
...existing,
|
||||||
|
...input,
|
||||||
|
id: existing.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
attendances[index] = updated;
|
||||||
|
await writeCsv(ATTENDANCES_FILE, attendances);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAttendance(id: string): Promise<boolean> {
|
||||||
|
const attendances = await getAttendances();
|
||||||
|
const filtered = attendances.filter((a) => a.id !== id);
|
||||||
|
if (filtered.length === attendances.length) return false;
|
||||||
|
await writeCsv(ATTENDANCES_FILE, filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type AttendanceType =
|
||||||
|
| 'PRESENT'
|
||||||
|
| 'SICK'
|
||||||
|
| 'ANNUAL_LEAVE'
|
||||||
|
| 'PERMIT'
|
||||||
|
| 'LATE_PERMIT'
|
||||||
|
| 'EARLY_LEAVE_PERMIT'
|
||||||
|
| 'OFFICIAL_TRAVEL';
|
||||||
|
|
||||||
|
export type WorkMode = 'WFO' | 'WFH' | 'OFF';
|
||||||
|
|
||||||
|
export type AttendanceStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'CONFIRMED';
|
||||||
|
|
||||||
|
export interface Attendance {
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
date: string;
|
||||||
|
type: AttendanceType;
|
||||||
|
work_mode: WorkMode;
|
||||||
|
clock_in: string | null;
|
||||||
|
clock_out: string | null;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
late_minutes: number;
|
||||||
|
status: AttendanceStatus;
|
||||||
|
reason_or_notes: string;
|
||||||
|
attachment_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttendanceWithEmployee extends Attendance {
|
||||||
|
employee?: import('./employee').Employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AttendanceInput = Omit<Attendance, 'id' | 'created_at'>;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export type UserRole = 'superadmin' | 'staff';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
role: UserRole;
|
||||||
|
employee_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthSession {
|
||||||
|
user: User;
|
||||||
|
employee?: import('./employee').Employee;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export type ContractType = 'PKWT' | 'PKWTT' | 'INTERNSHIP' | 'FREELANCE';
|
||||||
|
|
||||||
|
export interface EmployeeContract {
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
contract_number: string;
|
||||||
|
contract_type: ContractType;
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
salary: number;
|
||||||
|
notes: string;
|
||||||
|
document_url: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContractInput = Omit<EmployeeContract, 'id' | 'created_at'>;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Employee } from './employee';
|
||||||
|
import { Attendance } from './attendance';
|
||||||
|
|
||||||
|
export interface LeaderboardUser {
|
||||||
|
employee: Employee;
|
||||||
|
score: number;
|
||||||
|
presentCount: number;
|
||||||
|
onTimeCount: number;
|
||||||
|
streakCount: number;
|
||||||
|
rank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EarlyBirdHighlight {
|
||||||
|
employee: Employee;
|
||||||
|
averageClockIn: string; // e.g. "07:32:00"
|
||||||
|
earliestClockIn: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatecomerHighlight {
|
||||||
|
employee: Employee;
|
||||||
|
totalLateMinutes: number;
|
||||||
|
lateCount: number;
|
||||||
|
latestLateMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NightOwlHighlight {
|
||||||
|
employee: Employee;
|
||||||
|
averageClockOut: string; // e.g. "21:15:00"
|
||||||
|
latestClockOut: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStatusCount {
|
||||||
|
date: string;
|
||||||
|
totalEmployees: number;
|
||||||
|
presentCount: number;
|
||||||
|
wfoCount: number;
|
||||||
|
wfhCount: number;
|
||||||
|
leaveCount: number;
|
||||||
|
sickCount: number;
|
||||||
|
lateCount: number;
|
||||||
|
officialTravelCount: number;
|
||||||
|
pendingCount: number;
|
||||||
|
employees: {
|
||||||
|
present: { employee: Employee; record: Attendance }[];
|
||||||
|
leave: { employee: Employee; record: Attendance }[];
|
||||||
|
sick: { employee: Employee; record: Attendance }[];
|
||||||
|
late: { employee: Employee; record: Attendance }[];
|
||||||
|
travel: { employee: Employee; record: Attendance }[];
|
||||||
|
pending: { employee: Employee; record: Attendance }[];
|
||||||
|
unaccounted: Employee[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardAnalytics {
|
||||||
|
dateRange: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
preset?: string;
|
||||||
|
};
|
||||||
|
leaderboard: LeaderboardUser[];
|
||||||
|
earlyBird: EarlyBirdHighlight | null;
|
||||||
|
latecomer: LatecomerHighlight | null;
|
||||||
|
nightOwl: NightOwlHighlight | null;
|
||||||
|
todayStatus: DailyStatusCount;
|
||||||
|
yesterdayStatus: DailyStatusCount;
|
||||||
|
tomorrowStatus: DailyStatusCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export type EmploymentStatus = 'active' | 'probation' | 'resigned' | 'terminated';
|
||||||
|
export type WorkLocationDefault = 'WFO' | 'WFH' | 'HYBRID';
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: string;
|
||||||
|
nik: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
department: string;
|
||||||
|
position: string;
|
||||||
|
status: EmploymentStatus;
|
||||||
|
join_date: string;
|
||||||
|
work_location_default: WorkLocationDefault;
|
||||||
|
photo_url: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeInput = Omit<Employee, 'id' | 'created_at' | 'updated_at'> & {
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
primary: "#1b4ef5", // Cobalt Electric
|
||||||
|
secondary: "#3874ff", // Royal Azure
|
||||||
|
tertiary: "#5996ff", // Sky Periwinkle
|
||||||
|
pastel: "#f4ceff", // Lilac Mist / Glow
|
||||||
|
hover: "#143ec4",
|
||||||
|
dark: "#0d2b99",
|
||||||
|
light: "#ebf2ff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-jakarta)", "Plus Jakarta Sans", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
card: "0 1px 3px 0 rgba(0, 0, 0, 0.05), 0 1px 2px -1px rgba(0, 0, 0, 0.05)",
|
||||||
|
"card-hover": "0 10px 25px -5px rgba(27, 78, 245, 0.08), 0 8px 10px -6px rgba(27, 78, 245, 0.04)",
|
||||||
|
glow: "0 0 20px -3px rgba(244, 206, 255, 0.6)",
|
||||||
|
"brand-glow": "0 0 25px -5px rgba(27, 78, 245, 0.35)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user