Init
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
@@ -1,2 +1,85 @@
|
|||||||
# Employee_records
|
# Employee Records
|
||||||
|
|
||||||
|
Employee records management system built with Hono, Bun, and PocketBase.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 Authentication via PocketBase
|
||||||
|
- 📊 Dashboard with employee records and reports management
|
||||||
|
- 🎨 Modern UI with Tailwind CSS
|
||||||
|
- ⚡ Fast server with Hono and Bun
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend**: Hono (web framework) + Bun (runtime)
|
||||||
|
- **Frontend**: Vanilla JavaScript + Tailwind CSS
|
||||||
|
- **Database/Auth**: PocketBase (https://pocketbase.ccllc.pro)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh) installed on your system
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Set environment variables (optional):
|
||||||
|
```bash
|
||||||
|
export POCKETBASE_URL=https://pocketbase.ccllc.pro
|
||||||
|
export PORT=3000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will start on `http://localhost:3000`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Employee_records/
|
||||||
|
├── src/
|
||||||
|
│ ├── server.ts # Main Hono server entry point
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.ts # Authentication routes
|
||||||
|
│ │ └── api.ts # API routes
|
||||||
|
│ └── middleware/
|
||||||
|
│ └── auth.ts # Authentication middleware
|
||||||
|
├── public/
|
||||||
|
│ ├── index.html # Login page
|
||||||
|
│ ├── dashboard.html # Dashboard page
|
||||||
|
│ └── js/
|
||||||
|
│ ├── auth.js # Authentication client logic
|
||||||
|
│ └── dashboard.js # Dashboard logic
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## PocketBase Setup
|
||||||
|
|
||||||
|
Before using the application, ensure your PocketBase instance at `https://pocketbase.ccllc.pro` has:
|
||||||
|
|
||||||
|
1. A `users` collection (or the default auth collection)
|
||||||
|
2. User accounts created for login
|
||||||
|
|
||||||
|
The application will connect to this PocketBase instance for authentication.
|
||||||
|
|
||||||
|
## Collections
|
||||||
|
|
||||||
|
The following collections will be set up later:
|
||||||
|
- `employee_records` - Employee information
|
||||||
|
- `employee_reports` - Employee reports
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
- Run the server: `bun run dev`
|
||||||
|
- Type check: `bun run type-check`
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See LICENSE file for details.
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "employee-records",
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.0.0",
|
||||||
|
"pocketbase": "^0.21.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.11.0", "", {}, "sha512-Jg8uZzN2ul8/qlyid5FO8O624F3AK0wKtkgoeEON1qBum1rM1itYBxoMKu/1SPJC7F1+xlIZsJMmc4HHhJ1AWg=="],
|
||||||
|
|
||||||
|
"pocketbase": ["pocketbase@0.21.5", "", {}, "sha512-bnI/uinnQps+ElSlzxkc4yvwuSFfKcoszDtXH/4QT2FhGq2mJVUvDlxn+rjRXVntUjPfmMG5LEPZ1eGqV6ssog=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "employee-records",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Employee records management system with PocketBase",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run src/server.ts",
|
||||||
|
"start": "bun run src/server.ts",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.0.0",
|
||||||
|
"pocketbase": "^0.21.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Records - Dashboard</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
<!-- Navigation Header -->
|
||||||
|
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center h-auto sm:h-16 py-2 sm:py-0 gap-2 sm:gap-0">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-lg sm:text-xl font-bold text-gray-800">Employee Records</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 w-full sm:w-auto">
|
||||||
|
<div class="text-xs sm:text-sm text-gray-600">
|
||||||
|
<span id="userEmail">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="logoutButton"
|
||||||
|
class="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-6 sm:mb-8">
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Welcome, <span id="userName">User</span></h2>
|
||||||
|
<p class="text-sm sm:text-base text-gray-600">Manage your employee records and reports</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||||
|
<!-- Employee Records Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">Employee Records</h3>
|
||||||
|
<div class="w-12 h-12 bg-indigo-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 text-sm mb-4">View and manage employee information</p>
|
||||||
|
<a href="/employees.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm inline-block">
|
||||||
|
View Employees →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Reports Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-800">Employee Reports</h3>
|
||||||
|
<div class="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 text-sm mb-4">Generate and view employee reports</p>
|
||||||
|
<a href="/reports.html" class="text-green-600 hover:text-green-700 font-medium text-sm inline-block">
|
||||||
|
View Reports →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 sm:p-6 border border-gray-200 mb-6 sm:mb-8">
|
||||||
|
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-4">Quick Stats</h3>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<div class="text-center p-4 sm:p-6 bg-indigo-50 rounded-lg border border-indigo-100 hover:shadow-md transition-shadow">
|
||||||
|
<div class="text-3xl sm:text-4xl font-bold text-indigo-600 mb-2" id="totalEmployees">
|
||||||
|
<span class="inline-block animate-pulse">...</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs sm:text-sm font-medium text-gray-700">Total Employees</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center p-4 sm:p-6 bg-green-50 rounded-lg border border-green-100 hover:shadow-md transition-shadow">
|
||||||
|
<div class="text-3xl sm:text-4xl font-bold text-green-600 mb-2" id="totalReports">
|
||||||
|
<span class="inline-block animate-pulse">...</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs sm:text-sm font-medium text-gray-700">Reports Generated</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center p-4 sm:p-6 bg-purple-50 rounded-lg border border-purple-100 hover:shadow-md transition-shadow">
|
||||||
|
<div class="text-3xl sm:text-4xl font-bold text-purple-600 mb-2" id="activeEmployees">
|
||||||
|
<span class="inline-block animate-pulse">...</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs sm:text-sm font-medium text-gray-700">Active Employees</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-4 sm:p-6 border border-gray-200">
|
||||||
|
<h3 class="text-base sm:text-lg font-semibold text-gray-800 mb-4">Quick Actions</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<a href="/employees.html" class="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-indigo-300 transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-indigo-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 text-sm sm:text-base">Add New Employee</div>
|
||||||
|
<div class="text-xs sm:text-sm text-gray-500">Create a new employee record</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/reports.html" class="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-green-300 transition-colors">
|
||||||
|
<div class="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center mr-4 flex-shrink-0">
|
||||||
|
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 text-sm sm:text-base">Create New Report</div>
|
||||||
|
<div class="text-xs sm:text-sm text-gray-500">Generate an employee report</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/js/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Records - Employees</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
<!-- Quill.js Rich Text Editor -->
|
||||||
|
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
<!-- Navigation Header -->
|
||||||
|
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center h-auto sm:h-16 py-2 sm:py-0 gap-2 sm:gap-0">
|
||||||
|
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||||
|
<a href="/dashboard.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm sm:text-base">← Dashboard</a>
|
||||||
|
<h1 class="text-lg sm:text-xl font-bold text-gray-800">Employee Records</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 w-full sm:w-auto">
|
||||||
|
<div class="text-xs sm:text-sm text-gray-600">
|
||||||
|
<span id="userEmail">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="logoutButton"
|
||||||
|
class="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Employee Records</h2>
|
||||||
|
<p class="text-sm sm:text-base text-gray-600">Manage employee information</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="addEmployeeBtn"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm sm:text-base font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
+ Add Employee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error/Success Messages -->
|
||||||
|
<div id="messageContainer" class="mb-4"></div>
|
||||||
|
|
||||||
|
<!-- Search and Filter Section -->
|
||||||
|
<div class="mb-6 bg-white rounded-lg shadow p-4">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="w-full">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="searchInput"
|
||||||
|
placeholder="Search by name or ID..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
id="filterAll"
|
||||||
|
class="flex-1 sm:flex-none px-4 py-2 rounded-lg text-sm sm:text-base font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 bg-indigo-600 text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="filterEmployee"
|
||||||
|
class="flex-1 sm:flex-none px-4 py-2 rounded-lg text-sm sm:text-base font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Employee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="filterSubContractor"
|
||||||
|
class="flex-1 sm:flex-none px-4 py-2 rounded-lg text-sm sm:text-base font-medium transition focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Sub-Contractor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Records Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">First Name</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Last Name</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="employeesTableBody" class="bg-white divide-y divide-gray-200">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">Loading...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" class="mt-4 flex justify-between items-center"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add/Edit Employee Modal -->
|
||||||
|
<div id="employeeModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-0 sm:top-10 mx-auto p-4 sm:p-5 border-0 sm:border w-full sm:max-w-4xl shadow-lg rounded-none sm:rounded-md bg-white mb-0 sm:mb-10 min-h-screen sm:min-h-0">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900" id="modalTitle">Add Employee</h3>
|
||||||
|
<button id="closeEmployeeModal" class="text-gray-400 hover:text-gray-500">
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="border-b border-gray-200 mb-4">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button id="employeeInfoTab" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm active-tab">
|
||||||
|
Employee Info
|
||||||
|
</button>
|
||||||
|
<button id="employeeReportsTab" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm" id="employeeId" style="display: none;">
|
||||||
|
Reports
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Info Tab Content -->
|
||||||
|
<div id="employeeInfoContent">
|
||||||
|
<form id="employeeForm" class="space-y-4">
|
||||||
|
<input type="hidden" id="employeeId" name="id">
|
||||||
|
|
||||||
|
<!-- First Name and Last Name -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="firstName" class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="firstName"
|
||||||
|
name="First_Name"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="lastName" class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="lastName"
|
||||||
|
name="Last_Name"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email and Phone Number -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="Email"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="phoneNumber" class="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="phoneNumber"
|
||||||
|
name="Phone_Number"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voxer ID and Type -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="voxerId" class="block text-sm font-medium text-gray-700 mb-1">Voxer ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="voxerId"
|
||||||
|
name="Voxer_ID"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="type" class="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
name="Type"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base"
|
||||||
|
>
|
||||||
|
<option value="">Select a type...</option>
|
||||||
|
<option value="Employee">Employee</option>
|
||||||
|
<option value="Sub-Contractor">Sub-Contractor</option>
|
||||||
|
<option value="Applicant">Applicant</option>
|
||||||
|
<option value="None-Hire">None-Hire</option>
|
||||||
|
<option value="Former">Former</option>
|
||||||
|
<option value="Fired">Fired</option>
|
||||||
|
<option value="Blacklisted">Blacklisted</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="cancelBtn"
|
||||||
|
class="w-full sm:w-auto px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 text-base"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full sm:w-auto px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 text-base"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Reports Tab Content -->
|
||||||
|
<div id="employeeReportsContent" class="hidden">
|
||||||
|
<div class="mb-4">
|
||||||
|
<button
|
||||||
|
id="addReportBtn"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
+ Add Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reports List (Post Board Style) -->
|
||||||
|
<div id="employeeReportsList" class="space-y-4 max-h-[400px] sm:max-h-[500px] overflow-y-auto">
|
||||||
|
<div class="text-center text-gray-500 py-8">Loading reports...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Report Form (Hidden by default) -->
|
||||||
|
<div id="addReportForm" class="hidden mt-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<h4 class="text-md font-medium text-gray-900 mb-4">Add New Report</h4>
|
||||||
|
<form id="quickReportForm" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Report</label>
|
||||||
|
<div id="quickReportEditor" style="height: 150px;" class="mb-2 bg-white"></div>
|
||||||
|
<textarea id="quickReport" style="display: none;"></textarea>
|
||||||
|
<style>
|
||||||
|
#quickReportEditor .ql-editor {
|
||||||
|
color: #1f2937 !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
#quickReportEditor .ql-editor.ql-blank::before {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Sentiment</label>
|
||||||
|
<select id="quickSentiment" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Positive">Positive</option>
|
||||||
|
<option value="Negative">Negative</option>
|
||||||
|
<option value="Neutral">Neutral</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Date & Time</label>
|
||||||
|
<input type="datetime-local" id="quickReportDate" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Link 1</label>
|
||||||
|
<input type="url" id="quickLink01" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Link 2</label>
|
||||||
|
<input type="url" id="quickLink02" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Link 3</label>
|
||||||
|
<input type="url" id="quickLink03" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Link 4</label>
|
||||||
|
<input type="url" id="quickLink04" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Link 5</label>
|
||||||
|
<input type="url" id="quickLink05" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-2">
|
||||||
|
<button type="button" id="cancelQuickReport" class="w-full sm:w-auto px-3 py-2 text-sm bg-gray-200 text-gray-700 rounded hover:bg-gray-300">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="w-full sm:w-auto px-3 py-2 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700">
|
||||||
|
Save Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/employees.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Records - Login</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-8">
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 mb-2">Employee Records</h1>
|
||||||
|
<p class="text-gray-600">Sign in to your account</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="loginForm" class="space-y-6">
|
||||||
|
<div id="errorMessage" class="hidden bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition text-base"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition text-base"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
id="submitButton"
|
||||||
|
class="w-full bg-indigo-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/auth.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
// Initialize PocketBase client
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Load existing auth from cookies (PocketBase stores auth in cookies automatically)
|
||||||
|
try {
|
||||||
|
pb.authStore.loadFromCookie(document.cookie);
|
||||||
|
} catch (e) {
|
||||||
|
// If cookie loading fails, try localStorage
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedToken && storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('Error loading auth:', e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already authenticated
|
||||||
|
function checkAuth() {
|
||||||
|
return pb.authStore.isValid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login form submission
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const loginForm = document.getElementById('loginForm');
|
||||||
|
const errorMessage = document.getElementById('errorMessage');
|
||||||
|
|
||||||
|
if (loginForm) {
|
||||||
|
loginForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value;
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const submitButton = document.getElementById('submitButton');
|
||||||
|
const originalButtonText = submitButton.textContent;
|
||||||
|
|
||||||
|
// Disable button and show loading state
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Logging in...';
|
||||||
|
errorMessage.textContent = '';
|
||||||
|
errorMessage.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Authenticate with PocketBase (automatically stores in cookies)
|
||||||
|
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||||
|
|
||||||
|
// Store token in localStorage as backup
|
||||||
|
if (authData && authData.token) {
|
||||||
|
localStorage.setItem('pb_auth_token', authData.token);
|
||||||
|
localStorage.setItem('pb_auth_model', JSON.stringify(authData.record));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
errorMessage.textContent = error.message || 'Invalid email or password';
|
||||||
|
errorMessage.classList.remove('hidden');
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = originalButtonText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If already authenticated, redirect to dashboard
|
||||||
|
if (checkAuth() && (window.location.pathname === '/index.html' || window.location.pathname === '/')) {
|
||||||
|
window.location.href = '/dashboard.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
// Initialize PocketBase client
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Check authentication and load user data
|
||||||
|
async function initDashboard() {
|
||||||
|
// Load auth from cookies first
|
||||||
|
try {
|
||||||
|
pb.authStore.loadFromCookie(document.cookie);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading from cookie:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not valid from cookie, try loading from localStorage
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
|
||||||
|
if (storedToken && storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading from localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
// Try to refresh the token if we have a token but it's expired
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
try {
|
||||||
|
await pb.collection('users').authRefresh();
|
||||||
|
// Update localStorage after refresh
|
||||||
|
if (pb.authStore.token && pb.authStore.model) {
|
||||||
|
localStorage.setItem('pb_auth_token', pb.authStore.token);
|
||||||
|
localStorage.setItem('pb_auth_model', JSON.stringify(pb.authStore.model));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Token refresh failed:', e);
|
||||||
|
// If refresh fails, clear everything and redirect to login
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No token at all, redirect to login
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Display user information
|
||||||
|
const user = pb.authStore.model;
|
||||||
|
if (user) {
|
||||||
|
const userEmailElement = document.getElementById('userEmail');
|
||||||
|
const userNameElement = document.getElementById('userName');
|
||||||
|
|
||||||
|
if (userEmailElement) {
|
||||||
|
userEmailElement.textContent = user.email || 'User';
|
||||||
|
}
|
||||||
|
if (userNameElement) {
|
||||||
|
userNameElement.textContent = user.username || user.email || 'User';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No user model, redirect to login
|
||||||
|
console.error('No user model found');
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Dashboard init error:', error);
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth token for API requests
|
||||||
|
function getAuthToken() {
|
||||||
|
return pb.authStore.token || localStorage.getItem('pb_auth_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load dashboard statistics
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employees stats
|
||||||
|
try {
|
||||||
|
const employeesResponse = await fetch('/api/employees?page=1&perPage=1000', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (employeesResponse.ok) {
|
||||||
|
const employeesData = await employeesResponse.json();
|
||||||
|
const totalEmployeesElement = document.getElementById('totalEmployees');
|
||||||
|
if (totalEmployeesElement) {
|
||||||
|
totalEmployeesElement.innerHTML = employeesData.totalItems || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count active employees (those with Type = "Employee")
|
||||||
|
const activeCount = employeesData.items?.filter(emp => emp.Type === 'Employee').length || 0;
|
||||||
|
const activeEmployeesElement = document.getElementById('activeEmployees');
|
||||||
|
if (activeEmployeesElement) {
|
||||||
|
activeEmployeesElement.innerHTML = activeCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading employee stats:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reports stats
|
||||||
|
try {
|
||||||
|
const reportsResponse = await fetch('/api/reports?page=1&perPage=1', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reportsResponse.ok) {
|
||||||
|
const reportsData = await reportsResponse.json();
|
||||||
|
const totalReportsElement = document.getElementById('totalReports');
|
||||||
|
if (totalReportsElement) {
|
||||||
|
totalReportsElement.innerHTML = reportsData.totalItems || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading report stats:', error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logout
|
||||||
|
function handleLogout() {
|
||||||
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
|
|
||||||
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
// Clear auth store anyway
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize dashboard when page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
await initDashboard();
|
||||||
|
handleLogout();
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,985 @@
|
|||||||
|
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Load auth from localStorage or cookies
|
||||||
|
try {
|
||||||
|
pb.authStore.loadFromCookie(document.cookie);
|
||||||
|
} catch (e) {
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedToken && storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('Error loading auth:', e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentPage = 1;
|
||||||
|
const perPage = 50;
|
||||||
|
let allEmployees = [];
|
||||||
|
let currentFilter = 'all';
|
||||||
|
let currentSearch = '';
|
||||||
|
let currentEditingEmployeeId = null;
|
||||||
|
let quickReportEditor = null;
|
||||||
|
|
||||||
|
// Get auth token for API requests
|
||||||
|
function getAuthToken() {
|
||||||
|
// Try to get from PocketBase auth store first
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
return pb.authStore.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
if (storedToken) {
|
||||||
|
// Also update the auth store with the token
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading auth from localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show message
|
||||||
|
function showMessage(message, type = 'success') {
|
||||||
|
const container = document.getElementById('messageContainer');
|
||||||
|
const bgColor = type === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="${bgColor} border px-4 py-3 rounded-lg">
|
||||||
|
${message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employees
|
||||||
|
async function loadEmployees(page = 1) {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
console.log('Loading employees, token present:', !!token);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.error('No auth token found');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/employees?page=${page}&perPage=${perPage}&sort=Last_Name`;
|
||||||
|
console.log('Fetching from:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Response status:', response.status);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.error('Unauthorized - redirecting to login');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error('Error response:', error);
|
||||||
|
throw new Error(error.error || 'Failed to load employees');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Successfully loaded employees:', data);
|
||||||
|
allEmployees = data.items;
|
||||||
|
applyFiltersAndSearch();
|
||||||
|
displayPagination(data);
|
||||||
|
currentPage = page;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading employees:', error);
|
||||||
|
showMessage(error.message || 'Failed to load employees', 'error');
|
||||||
|
document.getElementById('employeesTableBody').innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-red-500">Error loading employees: ${error.message}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and search employees
|
||||||
|
function applyFiltersAndSearch() {
|
||||||
|
let filtered = [...allEmployees];
|
||||||
|
|
||||||
|
// Apply type filter
|
||||||
|
if (currentFilter === 'employee') {
|
||||||
|
filtered = filtered.filter(emp => emp.Type === 'Employee');
|
||||||
|
} else if (currentFilter === 'sub-contractor') {
|
||||||
|
filtered = filtered.filter(emp => emp.Type === 'Sub-Contractor');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search
|
||||||
|
if (currentSearch.trim()) {
|
||||||
|
const searchLower = currentSearch.toLowerCase().trim();
|
||||||
|
filtered = filtered.filter(emp => {
|
||||||
|
const firstName = (emp.First_Name || '').toLowerCase();
|
||||||
|
const lastName = (emp.Last_Name || '').toLowerCase();
|
||||||
|
const id = (emp.id || '').toLowerCase();
|
||||||
|
const fullName = `${firstName} ${lastName}`;
|
||||||
|
return firstName.includes(searchLower) ||
|
||||||
|
lastName.includes(searchLower) ||
|
||||||
|
fullName.includes(searchLower) ||
|
||||||
|
id.includes(searchLower);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by last name A to Z
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const lastNameA = (a.Last_Name || '').toLowerCase();
|
||||||
|
const lastNameB = (b.Last_Name || '').toLowerCase();
|
||||||
|
if (lastNameA < lastNameB) return -1;
|
||||||
|
if (lastNameA > lastNameB) return 1;
|
||||||
|
// If last names are the same, sort by first name
|
||||||
|
const firstNameA = (a.First_Name || '').toLowerCase();
|
||||||
|
const firstNameB = (b.First_Name || '').toLowerCase();
|
||||||
|
if (firstNameA < firstNameB) return -1;
|
||||||
|
if (firstNameA > firstNameB) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
displayEmployees(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display employees in table
|
||||||
|
function displayEmployees(employees) {
|
||||||
|
const tbody = document.getElementById('employeesTableBody');
|
||||||
|
|
||||||
|
if (employees.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500">No employees found</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = employees.map(emp => `
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-900">${emp.id || '-'}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-900">${emp.First_Name || '-'}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-900">${emp.Last_Name || '-'}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-500">${emp.Type || '-'}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button onclick="editEmployee('${emp.id}')" class="text-indigo-600 hover:text-indigo-900 mr-2 sm:mr-3 py-1 px-2 sm:px-0">Edit</button>
|
||||||
|
<button onclick="deleteEmployee('${emp.id}')" class="text-red-600 hover:text-red-900 py-1 px-2 sm:px-0">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filter button styles
|
||||||
|
function updateFilterButtons() {
|
||||||
|
const allBtn = document.getElementById('filterAll');
|
||||||
|
const employeeBtn = document.getElementById('filterEmployee');
|
||||||
|
const subContractorBtn = document.getElementById('filterSubContractor');
|
||||||
|
|
||||||
|
// Reset all buttons
|
||||||
|
[allBtn, employeeBtn, subContractorBtn].forEach(btn => {
|
||||||
|
btn.classList.remove('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||||
|
btn.classList.add('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set active button
|
||||||
|
let activeBtn;
|
||||||
|
if (currentFilter === 'all') {
|
||||||
|
activeBtn = allBtn;
|
||||||
|
} else if (currentFilter === 'employee') {
|
||||||
|
activeBtn = employeeBtn;
|
||||||
|
} else if (currentFilter === 'sub-contractor') {
|
||||||
|
activeBtn = subContractorBtn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeBtn) {
|
||||||
|
activeBtn.classList.remove('bg-gray-200', 'text-gray-700', 'hover:bg-gray-300');
|
||||||
|
activeBtn.classList.add('bg-indigo-600', 'text-white', 'hover:bg-indigo-700');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display pagination
|
||||||
|
function displayPagination(data) {
|
||||||
|
const pagination = document.getElementById('pagination');
|
||||||
|
|
||||||
|
if (data.totalPages <= 1) {
|
||||||
|
pagination.innerHTML = `<div class="text-sm text-gray-500">Total: ${data.totalItems} employees</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pagination.innerHTML = `
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Showing ${(data.page - 1) * data.perPage + 1} to ${Math.min(data.page * data.perPage, data.totalItems)} of ${data.totalItems} employees
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onclick="loadEmployees(${data.page - 1})"
|
||||||
|
${data.page === 1 ? 'disabled' : ''}
|
||||||
|
class="px-3 py-1 border rounded-lg ${data.page === 1 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'}"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<span class="px-3 py-1 text-gray-700">Page ${data.page} of ${data.totalPages}</span>
|
||||||
|
<button
|
||||||
|
onclick="loadEmployees(${data.page + 1})"
|
||||||
|
${data.page === data.totalPages ? 'disabled' : ''}
|
||||||
|
class="px-3 py-1 border rounded-lg ${data.page === data.totalPages ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'}"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modal for adding employee
|
||||||
|
function openAddModal() {
|
||||||
|
currentEditingEmployeeId = null;
|
||||||
|
document.getElementById('modalTitle').textContent = 'Add Employee';
|
||||||
|
document.getElementById('employeeForm').reset();
|
||||||
|
document.getElementById('employeeId').value = '';
|
||||||
|
|
||||||
|
// Hide Reports tab for new employees
|
||||||
|
document.getElementById('employeeReportsTab').style.display = 'none';
|
||||||
|
|
||||||
|
// Show Employee Info tab
|
||||||
|
switchToEmployeeInfoTab();
|
||||||
|
|
||||||
|
document.getElementById('employeeModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to Employee Info tab
|
||||||
|
function switchToEmployeeInfoTab() {
|
||||||
|
document.getElementById('employeeInfoTab').classList.add('border-indigo-500', 'text-indigo-600');
|
||||||
|
document.getElementById('employeeInfoTab').classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
document.getElementById('employeeReportsTab').classList.remove('border-indigo-500', 'text-indigo-600');
|
||||||
|
document.getElementById('employeeReportsTab').classList.add('border-transparent', 'text-gray-500');
|
||||||
|
document.getElementById('employeeInfoContent').classList.remove('hidden');
|
||||||
|
document.getElementById('employeeReportsContent').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to Reports tab
|
||||||
|
function switchToReportsTab() {
|
||||||
|
document.getElementById('employeeReportsTab').classList.add('border-indigo-500', 'text-indigo-600');
|
||||||
|
document.getElementById('employeeReportsTab').classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
document.getElementById('employeeInfoTab').classList.remove('border-indigo-500', 'text-indigo-600');
|
||||||
|
document.getElementById('employeeInfoTab').classList.add('border-transparent', 'text-gray-500');
|
||||||
|
document.getElementById('employeeInfoContent').classList.add('hidden');
|
||||||
|
document.getElementById('employeeReportsContent').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize quick report editor
|
||||||
|
function initQuickReportEditor() {
|
||||||
|
if (!quickReportEditor) {
|
||||||
|
const editorContainer = document.getElementById('quickReportEditor');
|
||||||
|
if (editorContainer && typeof Quill !== 'undefined') {
|
||||||
|
try {
|
||||||
|
quickReportEditor = new Quill('#quickReportEditor', {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
['link'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force text color to be dark
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#quickReportEditor .ql-editor {
|
||||||
|
color: #1f2937 !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
}
|
||||||
|
#quickReportEditor .ql-editor.ql-blank::before {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
#quickReportEditor .ql-editor p,
|
||||||
|
#quickReportEditor .ql-editor h1,
|
||||||
|
#quickReportEditor .ql-editor h2,
|
||||||
|
#quickReportEditor .ql-editor h3 {
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
quickReportEditor.on('text-change', function() {
|
||||||
|
const htmlContent = quickReportEditor.root.innerHTML;
|
||||||
|
document.getElementById('quickReport').value = htmlContent;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing quick report editor:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return quickReportEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employee reports
|
||||||
|
async function loadEmployeeReports(employeeId) {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`/api/reports?employee=${employeeId}&perPage=1000&sort=-Report_Date`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
displayEmployeeReports(data.items || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading employee reports:', error);
|
||||||
|
document.getElementById('employeeReportsList').innerHTML = '<div class="text-center text-red-500 py-4">Failed to load reports</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display employee reports in post board style
|
||||||
|
function displayEmployeeReports(reports) {
|
||||||
|
const container = document.getElementById('employeeReportsList');
|
||||||
|
|
||||||
|
if (reports.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center text-gray-500 py-8">No reports yet. Add one to get started!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportsHtml = reports.map(report => {
|
||||||
|
// Format date and time (convert from UTC to local for display)
|
||||||
|
const reportDate = report.Report_Date
|
||||||
|
? new Date(report.Report_Date).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
})
|
||||||
|
: 'No date';
|
||||||
|
|
||||||
|
const sentimentColor = {
|
||||||
|
'Positive': 'bg-green-100 text-green-800',
|
||||||
|
'Negative': 'bg-red-100 text-red-800',
|
||||||
|
'Neutral': 'bg-gray-100 text-gray-800'
|
||||||
|
}[report.Centiment] || 'bg-gray-100 text-gray-800';
|
||||||
|
|
||||||
|
const reportText = report.Report || '';
|
||||||
|
|
||||||
|
// Collect all links and detect Voxer links
|
||||||
|
const links = [];
|
||||||
|
const isVoxerLink = (url) => {
|
||||||
|
if (!url) return false;
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return lowerUrl.includes('voxer.com') || lowerUrl.includes('voxer');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (report.Link_01) links.push({ num: 1, url: report.Link_01, isVoxer: isVoxerLink(report.Link_01) });
|
||||||
|
if (report.Link_02) links.push({ num: 2, url: report.Link_02, isVoxer: isVoxerLink(report.Link_02) });
|
||||||
|
if (report.Link_03) links.push({ num: 3, url: report.Link_03, isVoxer: isVoxerLink(report.Link_03) });
|
||||||
|
if (report.Link_04) links.push({ num: 4, url: report.Link_04, isVoxer: isVoxerLink(report.Link_04) });
|
||||||
|
if (report.Link_05) links.push({ num: 5, url: report.Link_05, isVoxer: isVoxerLink(report.Link_05) });
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bg-white border border-gray-200 rounded-lg p-3 sm:p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 sm:gap-0 mb-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-xs sm:text-sm font-medium text-gray-900">📅 ${reportDate}</span>
|
||||||
|
${report.Centiment ? `<span class="px-2 py-1 text-xs font-medium rounded ${sentimentColor}">${escapeHtml(report.Centiment)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="editEmployeeReport('${report.id}')" class="text-indigo-600 hover:text-indigo-800 text-xs sm:text-sm py-1 px-2">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteEmployeeReport('${report.id}')" class="text-red-600 hover:text-red-800 text-xs sm:text-sm py-1 px-2 -mr-2 sm:mr-0 sm:px-0">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm max-w-none text-gray-700 mb-3">
|
||||||
|
${report.Report || '<em>No content</em>'}
|
||||||
|
</div>
|
||||||
|
${links.length > 0 ? `
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200">
|
||||||
|
<div class="text-xs font-medium text-gray-500 mb-2">Links:</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
${links.map(link => {
|
||||||
|
if (link.isVoxer) {
|
||||||
|
return `
|
||||||
|
<a href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 rounded-lg border border-purple-700 transition-colors shadow-sm">
|
||||||
|
🎙️ Voxer ${link.num}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
<a href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-sm font-medium text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded-lg border border-indigo-200 transition-colors">
|
||||||
|
🔗 Link ${link.num}
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = reportsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit employee report
|
||||||
|
let currentEditingReportId = null;
|
||||||
|
|
||||||
|
window.editEmployeeReport = async function(reportId) {
|
||||||
|
try {
|
||||||
|
currentEditingReportId = reportId;
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`/api/reports/${reportId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load report');
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await response.json();
|
||||||
|
|
||||||
|
// Populate the quick report form
|
||||||
|
initQuickReportEditor();
|
||||||
|
|
||||||
|
// Set rich text editor content
|
||||||
|
const reportContent = report.Report || '';
|
||||||
|
setTimeout(() => {
|
||||||
|
if (quickReportEditor) {
|
||||||
|
quickReportEditor.root.innerHTML = reportContent || '<p><br></p>';
|
||||||
|
document.getElementById('quickReport').value = reportContent;
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
document.getElementById('quickSentiment').value = report.Centiment || '';
|
||||||
|
|
||||||
|
// Format date and time for input field (convert from UTC to local)
|
||||||
|
if (report.Report_Date) {
|
||||||
|
const date = new Date(report.Report_Date);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
document.getElementById('quickReportDate').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('quickReportDate').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set links
|
||||||
|
document.getElementById('quickLink01').value = report.Link_01 || '';
|
||||||
|
document.getElementById('quickLink02').value = report.Link_02 || '';
|
||||||
|
document.getElementById('quickLink03').value = report.Link_03 || '';
|
||||||
|
document.getElementById('quickLink04').value = report.Link_04 || '';
|
||||||
|
document.getElementById('quickLink05').value = report.Link_05 || '';
|
||||||
|
|
||||||
|
// Update form title
|
||||||
|
const formTitle = document.querySelector('#addReportForm h4');
|
||||||
|
if (formTitle) {
|
||||||
|
formTitle.textContent = 'Edit Report';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the form
|
||||||
|
document.getElementById('addReportForm').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Scroll to form
|
||||||
|
document.getElementById('addReportForm').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading report:', error);
|
||||||
|
showMessage('Failed to load report', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete employee report
|
||||||
|
window.deleteEmployeeReport = async function(reportId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this report?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`/api/reports/${reportId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete report');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload reports
|
||||||
|
if (currentEditingEmployeeId) {
|
||||||
|
await loadEmployeeReports(currentEditingEmployeeId);
|
||||||
|
}
|
||||||
|
showMessage('Report deleted successfully', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting report:', error);
|
||||||
|
showMessage('Failed to delete report', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Edit employee
|
||||||
|
async function editEmployee(id) {
|
||||||
|
try {
|
||||||
|
currentEditingEmployeeId = id;
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`/api/employees/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load employee');
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await response.json();
|
||||||
|
|
||||||
|
document.getElementById('modalTitle').textContent = 'Edit Employee';
|
||||||
|
document.getElementById('employeeId').value = employee.id || '';
|
||||||
|
document.getElementById('firstName').value = employee.First_Name || '';
|
||||||
|
document.getElementById('lastName').value = employee.Last_Name || '';
|
||||||
|
document.getElementById('email').value = employee.Email || '';
|
||||||
|
document.getElementById('phoneNumber').value = employee.Phone_Number || '';
|
||||||
|
document.getElementById('voxerId').value = employee.Voxer_ID || '';
|
||||||
|
document.getElementById('type').value = employee.Type || '';
|
||||||
|
|
||||||
|
// Show Reports tab button
|
||||||
|
document.getElementById('employeeReportsTab').style.display = 'block';
|
||||||
|
|
||||||
|
// Show Employee Info tab by default
|
||||||
|
switchToEmployeeInfoTab();
|
||||||
|
|
||||||
|
// Load employee reports
|
||||||
|
await loadEmployeeReports(id);
|
||||||
|
|
||||||
|
document.getElementById('employeeModal').classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading employee:', error);
|
||||||
|
showMessage('Failed to load employee', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete employee
|
||||||
|
async function deleteEmployee(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this employee?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`/api/employees/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to delete employee');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage('Employee deleted successfully');
|
||||||
|
// Reset search and filter when reloading
|
||||||
|
currentSearch = '';
|
||||||
|
currentFilter = 'all';
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
}
|
||||||
|
updateFilterButtons();
|
||||||
|
loadEmployees(currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting employee:', error);
|
||||||
|
showMessage(error.message || 'Failed to delete employee', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize page - check auth and display user info
|
||||||
|
async function initPage() {
|
||||||
|
// First, ensure we have the token loaded from localStorage if not in auth store
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// No token at all, redirect to login
|
||||||
|
console.log('No auth token found, redirecting to login');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auth store is valid, if not try to refresh
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
try {
|
||||||
|
// Try to refresh the token
|
||||||
|
await pb.collection('users').authRefresh();
|
||||||
|
// Update localStorage after refresh
|
||||||
|
if (pb.authStore.token && pb.authStore.model) {
|
||||||
|
localStorage.setItem('pb_auth_token', pb.authStore.token);
|
||||||
|
localStorage.setItem('pb_auth_model', JSON.stringify(pb.authStore.model));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Token refresh failed:', e);
|
||||||
|
// If refresh fails but we have a token, still try to continue
|
||||||
|
// The API will validate it
|
||||||
|
if (!pb.authStore.token) {
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display user information
|
||||||
|
const user = pb.authStore.model;
|
||||||
|
if (user) {
|
||||||
|
const userEmailElement = document.getElementById('userEmail');
|
||||||
|
if (userEmailElement) {
|
||||||
|
userEmailElement.textContent = user.email || 'User';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to get user from localStorage
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedModel) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(storedModel);
|
||||||
|
const userEmailElement = document.getElementById('userEmail');
|
||||||
|
if (userEmailElement) {
|
||||||
|
userEmailElement.textContent = userData.email || 'User';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing stored user model:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logout button
|
||||||
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize page (auth check, user display, logout)
|
||||||
|
initPage();
|
||||||
|
|
||||||
|
// Load employees on page load
|
||||||
|
loadEmployees();
|
||||||
|
|
||||||
|
// Add employee button
|
||||||
|
document.getElementById('addEmployeeBtn').addEventListener('click', openAddModal);
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
document.getElementById('cancelBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('employeeModal').classList.add('hidden');
|
||||||
|
document.getElementById('addReportForm').classList.add('hidden');
|
||||||
|
if (quickReportEditor) {
|
||||||
|
quickReportEditor.setContents([]);
|
||||||
|
quickReportEditor.root.innerHTML = '<p><br></p>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal button
|
||||||
|
const closeModalBtn = document.getElementById('closeEmployeeModal');
|
||||||
|
if (closeModalBtn) {
|
||||||
|
closeModalBtn.addEventListener('click', () => {
|
||||||
|
document.getElementById('employeeModal').classList.add('hidden');
|
||||||
|
document.getElementById('addReportForm').classList.add('hidden');
|
||||||
|
if (quickReportEditor) {
|
||||||
|
quickReportEditor.setContents([]);
|
||||||
|
quickReportEditor.root.innerHTML = '<p><br></p>';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
document.getElementById('employeeInfoTab').addEventListener('click', switchToEmployeeInfoTab);
|
||||||
|
document.getElementById('employeeReportsTab').addEventListener('click', switchToReportsTab);
|
||||||
|
|
||||||
|
// Add Report button
|
||||||
|
const addReportBtn = document.getElementById('addReportBtn');
|
||||||
|
if (addReportBtn) {
|
||||||
|
addReportBtn.addEventListener('click', () => {
|
||||||
|
currentEditingReportId = null;
|
||||||
|
initQuickReportEditor();
|
||||||
|
document.getElementById('addReportForm').classList.remove('hidden');
|
||||||
|
// Set current date/time as default
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
document.getElementById('quickReportDate').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
// Reset form title
|
||||||
|
const formTitle = document.querySelector('#addReportForm h4');
|
||||||
|
if (formTitle) {
|
||||||
|
formTitle.textContent = 'Add New Report';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel quick report
|
||||||
|
const cancelQuickReport = document.getElementById('cancelQuickReport');
|
||||||
|
if (cancelQuickReport) {
|
||||||
|
cancelQuickReport.addEventListener('click', () => {
|
||||||
|
document.getElementById('addReportForm').classList.add('hidden');
|
||||||
|
currentEditingReportId = null;
|
||||||
|
if (quickReportEditor) {
|
||||||
|
quickReportEditor.setContents([]);
|
||||||
|
quickReportEditor.root.innerHTML = '<p><br></p>';
|
||||||
|
}
|
||||||
|
document.getElementById('quickReportForm').reset();
|
||||||
|
// Clear all link fields
|
||||||
|
document.getElementById('quickLink01').value = '';
|
||||||
|
document.getElementById('quickLink02').value = '';
|
||||||
|
document.getElementById('quickLink03').value = '';
|
||||||
|
document.getElementById('quickLink04').value = '';
|
||||||
|
document.getElementById('quickLink05').value = '';
|
||||||
|
// Reset form title
|
||||||
|
const formTitle = document.querySelector('#addReportForm h4');
|
||||||
|
if (formTitle) {
|
||||||
|
formTitle.textContent = 'Add New Report';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick report form submission
|
||||||
|
const quickReportForm = document.getElementById('quickReportForm');
|
||||||
|
if (quickReportForm) {
|
||||||
|
quickReportForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!currentEditingEmployeeId) {
|
||||||
|
showMessage('Please save the employee first', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
// Convert datetime-local to UTC ISO string
|
||||||
|
let reportDateUTC = undefined;
|
||||||
|
const reportDateValue = document.getElementById('quickReportDate').value;
|
||||||
|
if (reportDateValue) {
|
||||||
|
const localDate = new Date(reportDateValue);
|
||||||
|
reportDateUTC = localDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
Employee: currentEditingEmployeeId,
|
||||||
|
Report: document.getElementById('quickReport').value || undefined,
|
||||||
|
Centiment: document.getElementById('quickSentiment').value || undefined,
|
||||||
|
Report_Date: reportDateUTC,
|
||||||
|
Link_01: document.getElementById('quickLink01').value || undefined,
|
||||||
|
Link_02: document.getElementById('quickLink02').value || undefined,
|
||||||
|
Link_03: document.getElementById('quickLink03').value || undefined,
|
||||||
|
Link_04: document.getElementById('quickLink04').value || undefined,
|
||||||
|
Link_05: document.getElementById('quickLink05').value || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = currentEditingReportId ? `/api/reports/${currentEditingReportId}` : '/api/reports';
|
||||||
|
const method = currentEditingReportId ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to save report');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(`Report ${currentEditingReportId ? 'updated' : 'added'} successfully`, 'success');
|
||||||
|
document.getElementById('addReportForm').classList.add('hidden');
|
||||||
|
quickReportForm.reset();
|
||||||
|
currentEditingReportId = null;
|
||||||
|
if (quickReportEditor) {
|
||||||
|
quickReportEditor.setContents([]);
|
||||||
|
quickReportEditor.root.innerHTML = '<p><br></p>';
|
||||||
|
}
|
||||||
|
// Clear all link fields
|
||||||
|
document.getElementById('quickLink01').value = '';
|
||||||
|
document.getElementById('quickLink02').value = '';
|
||||||
|
document.getElementById('quickLink03').value = '';
|
||||||
|
document.getElementById('quickLink04').value = '';
|
||||||
|
document.getElementById('quickLink05').value = '';
|
||||||
|
|
||||||
|
// Update form title back to "Add New Report"
|
||||||
|
const formTitle = document.querySelector('#addReportForm h4');
|
||||||
|
if (formTitle) {
|
||||||
|
formTitle.textContent = 'Add New Report';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload reports
|
||||||
|
await loadEmployeeReports(currentEditingEmployeeId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving report:', error);
|
||||||
|
showMessage(error.message || 'Failed to save report', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
currentSearch = e.target.value;
|
||||||
|
applyFiltersAndSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter buttons
|
||||||
|
const filterAll = document.getElementById('filterAll');
|
||||||
|
const filterEmployee = document.getElementById('filterEmployee');
|
||||||
|
const filterSubContractor = document.getElementById('filterSubContractor');
|
||||||
|
|
||||||
|
if (filterAll) {
|
||||||
|
filterAll.addEventListener('click', () => {
|
||||||
|
currentFilter = 'all';
|
||||||
|
updateFilterButtons();
|
||||||
|
applyFiltersAndSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterEmployee) {
|
||||||
|
filterEmployee.addEventListener('click', () => {
|
||||||
|
currentFilter = 'employee';
|
||||||
|
updateFilterButtons();
|
||||||
|
applyFiltersAndSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterSubContractor) {
|
||||||
|
filterSubContractor.addEventListener('click', () => {
|
||||||
|
currentFilter = 'sub-contractor';
|
||||||
|
updateFilterButtons();
|
||||||
|
applyFiltersAndSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize filter button styles
|
||||||
|
updateFilterButtons();
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
document.getElementById('employeeForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const employeeId = formData.get('id');
|
||||||
|
const data = {
|
||||||
|
First_Name: formData.get('First_Name') || '',
|
||||||
|
Last_Name: formData.get('Last_Name') || '',
|
||||||
|
Email: formData.get('Email') || '',
|
||||||
|
Phone_Number: formData.get('Phone_Number') || '',
|
||||||
|
Voxer_ID: formData.get('Voxer_ID') || '',
|
||||||
|
Type: formData.get('Type') || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Note: ID is only needed in URL for updates, not in body
|
||||||
|
// For creates, ID is optional and auto-generated if not provided
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const url = employeeId ? `/api/employees/${employeeId}` : '/api/employees';
|
||||||
|
const method = employeeId ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to save employee');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(`Employee ${employeeId ? 'updated' : 'created'} successfully`);
|
||||||
|
document.getElementById('employeeModal').classList.add('hidden');
|
||||||
|
// Reset search and filter when reloading
|
||||||
|
currentSearch = '';
|
||||||
|
currentFilter = 'all';
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
}
|
||||||
|
updateFilterButtons();
|
||||||
|
loadEmployees(currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving employee:', error);
|
||||||
|
showMessage(error.message || 'Failed to save employee', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility function to escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make functions globally available
|
||||||
|
window.editEmployee = editEmployee;
|
||||||
|
window.deleteEmployee = deleteEmployee;
|
||||||
|
window.loadEmployees = loadEmployees;
|
||||||
|
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
const POCKETBASE_URL = 'https://pocketbase.ccllc.pro';
|
||||||
|
const API_BASE = '/api/reports';
|
||||||
|
const EMPLOYEES_API = '/api/employees';
|
||||||
|
|
||||||
|
// Initialize PocketBase client
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Load auth from localStorage/cookies
|
||||||
|
try {
|
||||||
|
pb.authStore.loadFromCookie(document.cookie);
|
||||||
|
} catch (e) {
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedToken && storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e2) {
|
||||||
|
console.error('Error loading auth:', e2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth token for API requests
|
||||||
|
function getAuthToken() {
|
||||||
|
// Try to get from PocketBase auth store first
|
||||||
|
if (pb.authStore.token) {
|
||||||
|
return pb.authStore.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to localStorage
|
||||||
|
const storedToken = localStorage.getItem('pb_auth_token');
|
||||||
|
if (storedToken) {
|
||||||
|
// Also update the auth store with the token
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedModel) {
|
||||||
|
try {
|
||||||
|
pb.authStore.save(storedToken, JSON.parse(storedModel));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading auth from localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return storedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employees for dropdown
|
||||||
|
let employeesList = [];
|
||||||
|
|
||||||
|
// Rich text editor instance
|
||||||
|
let reportEditor = null;
|
||||||
|
|
||||||
|
async function loadEmployees() {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${EMPLOYEES_API}?page=1&perPage=1000`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load employees');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
employeesList = data.items || [];
|
||||||
|
populateEmployeeDropdown();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading employees:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateEmployeeDropdown() {
|
||||||
|
const employeeSelect = document.getElementById('Employee');
|
||||||
|
if (!employeeSelect) return;
|
||||||
|
|
||||||
|
// Clear existing options except the first one
|
||||||
|
while (employeeSelect.options.length > 1) {
|
||||||
|
employeeSelect.remove(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
employeesList.forEach(emp => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = emp.id;
|
||||||
|
const name = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || emp.id;
|
||||||
|
option.textContent = `${name} (${emp.id})`;
|
||||||
|
employeeSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show message
|
||||||
|
function showMessage(message, type = 'success') {
|
||||||
|
const container = document.getElementById('messageContainer');
|
||||||
|
const bgColor = type === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="${bgColor} border px-4 py-3 rounded-lg">
|
||||||
|
${message}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
container.innerHTML = '';
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display reports
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
async function loadReports(page = 1) {
|
||||||
|
const loadingIndicator = document.getElementById('loadingIndicator');
|
||||||
|
const tableContainer = document.getElementById('reportsTableContainer');
|
||||||
|
const emptyState = document.getElementById('emptyState');
|
||||||
|
const tableBody = document.getElementById('reportsTableBody');
|
||||||
|
|
||||||
|
loadingIndicator.classList.remove('hidden');
|
||||||
|
tableContainer.classList.add('hidden');
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}?page=${page}&perPage=50`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load reports');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
currentPage = data.page;
|
||||||
|
totalPages = data.totalPages;
|
||||||
|
|
||||||
|
loadingIndicator.classList.add('hidden');
|
||||||
|
|
||||||
|
if (data.items.length === 0) {
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableContainer.classList.remove('hidden');
|
||||||
|
renderReports(data.items);
|
||||||
|
renderPagination();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading reports:', error);
|
||||||
|
loadingIndicator.classList.add('hidden');
|
||||||
|
showMessage('Failed to load reports. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render reports table
|
||||||
|
function renderReports(reports) {
|
||||||
|
const tableBody = document.getElementById('reportsTableBody');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
reports.forEach(report => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.className = 'hover:bg-gray-50';
|
||||||
|
|
||||||
|
// Get employee name from expanded relation
|
||||||
|
let employeeName = '-';
|
||||||
|
if (report.expand && report.expand.Employee) {
|
||||||
|
const emp = report.expand.Employee;
|
||||||
|
employeeName = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || emp.id || '-';
|
||||||
|
} else if (report.Employee) {
|
||||||
|
employeeName = report.Employee;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format report text (strip HTML tags and truncate if too long)
|
||||||
|
const reportHtml = report.Report || '-';
|
||||||
|
// Strip HTML tags for preview
|
||||||
|
const reportText = reportHtml.replace(/<[^>]*>/g, '').trim();
|
||||||
|
const reportPreview = reportText.length > 100 ? reportText.substring(0, 100) + '...' : reportText;
|
||||||
|
|
||||||
|
// Format date and time (convert from UTC to local for display)
|
||||||
|
const reportDate = report.Report_Date
|
||||||
|
? new Date(report.Report_Date).toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZoneName: 'short'
|
||||||
|
})
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
// Count and display links (detect Voxer links)
|
||||||
|
const links = [];
|
||||||
|
const isVoxerLink = (url) => {
|
||||||
|
if (!url) return false;
|
||||||
|
const lowerUrl = url.toLowerCase();
|
||||||
|
return lowerUrl.includes('voxer.com') || lowerUrl.includes('voxer');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (report.Link_01) links.push({ num: 1, url: report.Link_01, isVoxer: isVoxerLink(report.Link_01) });
|
||||||
|
if (report.Link_02) links.push({ num: 2, url: report.Link_02, isVoxer: isVoxerLink(report.Link_02) });
|
||||||
|
if (report.Link_03) links.push({ num: 3, url: report.Link_03, isVoxer: isVoxerLink(report.Link_03) });
|
||||||
|
if (report.Link_04) links.push({ num: 4, url: report.Link_04, isVoxer: isVoxerLink(report.Link_04) });
|
||||||
|
if (report.Link_05) links.push({ num: 5, url: report.Link_05, isVoxer: isVoxerLink(report.Link_05) });
|
||||||
|
|
||||||
|
const linksHtml = links.length > 0
|
||||||
|
? links.map(link => {
|
||||||
|
if (link.isVoxer) {
|
||||||
|
return `<a href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-2 py-1 text-xs font-medium text-white bg-purple-600 hover:bg-purple-700 rounded">🎙️ Voxer ${link.num}</a>`;
|
||||||
|
} else {
|
||||||
|
return `<a href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer" class="inline-flex items-center px-2 py-1 text-xs font-medium text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded border border-indigo-200">🔗 Link ${link.num}</a>`;
|
||||||
|
}
|
||||||
|
}).join('')
|
||||||
|
: '-';
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-900">${escapeHtml(employeeName)}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 text-sm text-gray-900">
|
||||||
|
<div class="max-w-xs truncate" title="${escapeHtml(reportText)}">
|
||||||
|
${reportPreview !== '-' ? escapeHtml(reportPreview) : '-'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-500">${escapeHtml(report.Centiment || '-')}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-sm text-gray-500">${escapeHtml(reportDate)}</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 text-sm text-gray-500">
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${linksHtml === '-' ? '-' : linksHtml}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 sm:px-6 py-3 sm:py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<button onclick="editReport('${report.id}')" class="text-indigo-600 hover:text-indigo-900 mr-2 sm:mr-4 py-1 px-2 sm:px-0">Edit</button>
|
||||||
|
<button onclick="deleteReport('${report.id}')" class="text-red-600 hover:text-red-900 py-1 px-2 sm:px-0">Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tableBody.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render pagination
|
||||||
|
function renderPagination() {
|
||||||
|
const container = document.getElementById('paginationContainer');
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="flex items-center justify-between">';
|
||||||
|
html += '<div class="flex-1 flex justify-between sm:hidden">';
|
||||||
|
if (currentPage > 1) {
|
||||||
|
html += `<button onclick="loadReports(${currentPage - 1})" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Previous</button>`;
|
||||||
|
}
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
html += `<button onclick="loadReports(${currentPage + 1})" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">Next</button>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
html += '<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">';
|
||||||
|
html += `<p class="text-sm text-gray-700">Page <span class="font-medium">${currentPage}</span> of <span class="font-medium">${totalPages}</span></p>`;
|
||||||
|
html += '<div>';
|
||||||
|
if (currentPage > 1) {
|
||||||
|
html += `<button onclick="loadReports(${currentPage - 1})" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">Previous</button>`;
|
||||||
|
}
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
html += `<button onclick="loadReports(${currentPage + 1})" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">Next</button>`;
|
||||||
|
}
|
||||||
|
html += '</div></div></div>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize rich text editor
|
||||||
|
function initRichTextEditor() {
|
||||||
|
if (!reportEditor) {
|
||||||
|
const editorContainer = document.getElementById('reportEditor');
|
||||||
|
if (editorContainer && typeof Quill !== 'undefined') {
|
||||||
|
try {
|
||||||
|
reportEditor = new Quill('#reportEditor', {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
['link'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync editor content with hidden textarea for form submission
|
||||||
|
reportEditor.on('text-change', function() {
|
||||||
|
const htmlContent = reportEditor.root.innerHTML;
|
||||||
|
const textarea = document.getElementById('Report');
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = htmlContent;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error initializing rich text editor:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reportEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open modal for add/edit
|
||||||
|
async function openModal(reportId = null) {
|
||||||
|
const modal = document.getElementById('reportModal');
|
||||||
|
const form = document.getElementById('reportForm');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const reportIdInput = document.getElementById('reportId');
|
||||||
|
|
||||||
|
// Show modal first so editor container is visible
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Initialize rich text editor (with a small delay to ensure DOM is ready)
|
||||||
|
setTimeout(() => {
|
||||||
|
initRichTextEditor();
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
// Ensure employees are loaded
|
||||||
|
if (employeesList.length === 0) {
|
||||||
|
await loadEmployees();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reportId) {
|
||||||
|
modalTitle.textContent = 'Edit Employee Report';
|
||||||
|
reportIdInput.value = reportId;
|
||||||
|
await loadReportForEdit(reportId);
|
||||||
|
} else {
|
||||||
|
modalTitle.textContent = 'Add Employee Report';
|
||||||
|
reportIdInput.value = '';
|
||||||
|
form.reset();
|
||||||
|
// Clear the rich text editor after it's initialized
|
||||||
|
setTimeout(() => {
|
||||||
|
if (reportEditor) {
|
||||||
|
reportEditor.setContents([]);
|
||||||
|
reportEditor.root.innerHTML = '<p><br></p>';
|
||||||
|
document.getElementById('Report').value = '';
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load report data for editing
|
||||||
|
async function loadReportForEdit(id) {
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`${API_BASE}/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load report');
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await response.json();
|
||||||
|
document.getElementById('Employee').value = report.Employee || '';
|
||||||
|
|
||||||
|
// Set rich text editor content
|
||||||
|
const reportContent = report.Report || '';
|
||||||
|
if (reportEditor) {
|
||||||
|
// Set the HTML content in the editor
|
||||||
|
reportEditor.root.innerHTML = reportContent || '<p><br></p>';
|
||||||
|
document.getElementById('Report').value = reportContent;
|
||||||
|
} else {
|
||||||
|
// Editor not initialized yet, try again after a short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (reportEditor) {
|
||||||
|
reportEditor.root.innerHTML = reportContent || '<p><br></p>';
|
||||||
|
document.getElementById('Report').value = reportContent;
|
||||||
|
} else {
|
||||||
|
document.getElementById('Report').value = reportContent;
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('Centiment').value = report.Centiment || '';
|
||||||
|
|
||||||
|
// Format date and time for input field (YYYY-MM-DDTHH:mm) - convert from UTC to local
|
||||||
|
if (report.Report_Date) {
|
||||||
|
const date = new Date(report.Report_Date);
|
||||||
|
// Convert UTC to local time for display
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
document.getElementById('Report_Date').value = `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
|
} else {
|
||||||
|
document.getElementById('Report_Date').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('Link_01').value = report.Link_01 || '';
|
||||||
|
document.getElementById('Link_02').value = report.Link_02 || '';
|
||||||
|
document.getElementById('Link_03').value = report.Link_03 || '';
|
||||||
|
document.getElementById('Link_04').value = report.Link_04 || '';
|
||||||
|
document.getElementById('Link_05').value = report.Link_05 || '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading report:', error);
|
||||||
|
showMessage('Failed to load report data.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit report
|
||||||
|
window.editReport = function(id) {
|
||||||
|
openModal(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delete report
|
||||||
|
window.deleteReport = async function(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this report?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await fetch(`${API_BASE}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete report');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage('Report deleted successfully.', 'success');
|
||||||
|
loadReports(currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting report:', error);
|
||||||
|
showMessage('Failed to delete report.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize page - check auth and display user info
|
||||||
|
async function initPage() {
|
||||||
|
// First, ensure we have the token loaded from localStorage if not in auth store
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
// No token at all, redirect to login
|
||||||
|
console.log('No auth token found, redirecting to login');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if auth store is valid, if not try to refresh
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
try {
|
||||||
|
// Try to refresh the token
|
||||||
|
await pb.collection('users').authRefresh();
|
||||||
|
// Update localStorage after refresh
|
||||||
|
if (pb.authStore.token && pb.authStore.model) {
|
||||||
|
localStorage.setItem('pb_auth_token', pb.authStore.token);
|
||||||
|
localStorage.setItem('pb_auth_model', JSON.stringify(pb.authStore.model));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Token refresh failed:', e);
|
||||||
|
// If refresh fails but we have a token, still try to continue
|
||||||
|
// The API will validate it
|
||||||
|
if (!pb.authStore.token) {
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display user information
|
||||||
|
const user = pb.authStore.model;
|
||||||
|
if (user) {
|
||||||
|
const userEmailElement = document.getElementById('userEmail');
|
||||||
|
if (userEmailElement) {
|
||||||
|
userEmailElement.textContent = user.email || 'User';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to get user from localStorage
|
||||||
|
const storedModel = localStorage.getItem('pb_auth_model');
|
||||||
|
if (storedModel) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(storedModel);
|
||||||
|
const userEmailElement = document.getElementById('userEmail');
|
||||||
|
if (userEmailElement) {
|
||||||
|
userEmailElement.textContent = userData.email || 'User';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing stored user model:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle logout button
|
||||||
|
const logoutButton = document.getElementById('logoutButton');
|
||||||
|
if (logoutButton) {
|
||||||
|
logoutButton.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
pb.authStore.clear();
|
||||||
|
localStorage.removeItem('pb_auth_token');
|
||||||
|
localStorage.removeItem('pb_auth_model');
|
||||||
|
window.location.href = '/index.html';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
// Initialize page (auth check, user display, logout)
|
||||||
|
await initPage();
|
||||||
|
|
||||||
|
const form = document.getElementById('reportForm');
|
||||||
|
const modal = document.getElementById('reportModal');
|
||||||
|
const closeModal = document.getElementById('closeModal');
|
||||||
|
const cancelButton = document.getElementById('cancelButton');
|
||||||
|
const addReportButton = document.getElementById('addReportButton');
|
||||||
|
const addReportButtonEmpty = document.getElementById('addReportButtonEmpty');
|
||||||
|
|
||||||
|
// Load reports on page load
|
||||||
|
loadReports();
|
||||||
|
|
||||||
|
// Open modal handlers
|
||||||
|
if (addReportButton) {
|
||||||
|
addReportButton.addEventListener('click', () => openModal());
|
||||||
|
}
|
||||||
|
if (addReportButtonEmpty) {
|
||||||
|
addReportButtonEmpty.addEventListener('click', () => openModal());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal handlers
|
||||||
|
if (closeModal) {
|
||||||
|
closeModal.addEventListener('click', () => {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cancelButton) {
|
||||||
|
cancelButton.addEventListener('click', () => {
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const reportId = document.getElementById('reportId').value;
|
||||||
|
|
||||||
|
// Convert datetime-local to UTC ISO string
|
||||||
|
let reportDateUTC = undefined;
|
||||||
|
const reportDateValue = document.getElementById('Report_Date').value;
|
||||||
|
if (reportDateValue) {
|
||||||
|
const localDate = new Date(reportDateValue);
|
||||||
|
reportDateUTC = localDate.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = {
|
||||||
|
Employee: document.getElementById('Employee').value || undefined,
|
||||||
|
Report: document.getElementById('Report').value || undefined,
|
||||||
|
Centiment: document.getElementById('Centiment').value || undefined,
|
||||||
|
Report_Date: reportDateUTC,
|
||||||
|
Link_01: document.getElementById('Link_01').value || undefined,
|
||||||
|
Link_02: document.getElementById('Link_02').value || undefined,
|
||||||
|
Link_03: document.getElementById('Link_03').value || undefined,
|
||||||
|
Link_04: document.getElementById('Link_04').value || undefined,
|
||||||
|
Link_05: document.getElementById('Link_05').value || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = getAuthToken();
|
||||||
|
const url = reportId ? `${API_BASE}/${reportId}` : API_BASE;
|
||||||
|
const method = reportId ? 'PATCH' : 'POST';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to save report');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMessage(`Report ${reportId ? 'updated' : 'created'} successfully.`, 'success');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
loadReports(reportId ? currentPage : 1);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving report:', error);
|
||||||
|
showMessage(error.message || 'Failed to save report.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load employees when page loads
|
||||||
|
loadEmployees();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility function to escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Employee Reports - Employee Records</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
|
<!-- Quill.js Rich Text Editor -->
|
||||||
|
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 min-h-screen">
|
||||||
|
<!-- Navigation Header -->
|
||||||
|
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center h-auto sm:h-16 py-2 sm:py-0 gap-2 sm:gap-0">
|
||||||
|
<div class="flex items-center space-x-2 sm:space-x-4">
|
||||||
|
<a href="/dashboard.html" class="text-indigo-600 hover:text-indigo-700 font-medium text-sm sm:text-base">← Dashboard</a>
|
||||||
|
<h1 class="text-lg sm:text-xl font-bold text-gray-800">Employee Reports</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row items-start sm:items-center space-y-2 sm:space-y-0 sm:space-x-4 w-full sm:w-auto">
|
||||||
|
<div class="text-xs sm:text-sm text-gray-600">
|
||||||
|
<span id="userEmail">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="logoutButton"
|
||||||
|
class="bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<!-- Header with Add Button -->
|
||||||
|
<div class="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl sm:text-2xl font-bold text-gray-800 mb-2">Employee Reports</h2>
|
||||||
|
<p class="text-sm sm:text-base text-gray-600">Manage employee report records</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="addReportButton"
|
||||||
|
class="bg-indigo-600 text-white px-4 sm:px-6 py-2 sm:py-3 rounded-lg text-sm sm:text-base font-medium hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
+ Add Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error/Success Messages -->
|
||||||
|
<div id="messageContainer" class="mb-4"></div>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div id="loadingIndicator" class="text-center py-8">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600">Loading reports...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reports Table -->
|
||||||
|
<div id="reportsTableContainer" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Employee</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Report</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Sentiment</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Report Date</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Links</th>
|
||||||
|
<th class="px-3 sm:px-6 py-2 sm:py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="reportsTableBody" class="bg-white divide-y divide-gray-200">
|
||||||
|
<!-- Reports will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="paginationContainer" class="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
|
||||||
|
<!-- Pagination will be inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="emptyState" class="hidden text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900">No reports</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Get started by creating a new employee report.</p>
|
||||||
|
<div class="mt-6">
|
||||||
|
<button
|
||||||
|
id="addReportButtonEmpty"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
+ Add Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div id="reportModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
|
<div class="relative top-0 sm:top-10 mx-auto p-4 sm:p-5 border-0 sm:border w-full sm:max-w-2xl shadow-lg rounded-none sm:rounded-md bg-white mb-0 sm:mb-10 min-h-screen sm:min-h-0">
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900" id="modalTitle">Add Employee Report</h3>
|
||||||
|
<button id="closeModal" class="text-gray-400 hover:text-gray-500">
|
||||||
|
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="reportForm" class="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto pr-2">
|
||||||
|
<input type="hidden" id="reportId" />
|
||||||
|
<div>
|
||||||
|
<label for="Employee" class="block text-sm font-medium text-gray-700 mb-1">Employee *</label>
|
||||||
|
<select id="Employee" name="Employee" required class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||||
|
<option value="">Select an employee...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Report" class="block text-sm font-medium text-gray-700 mb-1">Report</label>
|
||||||
|
<div id="reportEditor" style="height: 200px;" class="mb-2"></div>
|
||||||
|
<textarea id="Report" name="Report" style="display: none;"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Centiment" class="block text-sm font-medium text-gray-700 mb-1">Sentiment</label>
|
||||||
|
<select id="Centiment" name="Centiment" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base">
|
||||||
|
<option value="">Select sentiment...</option>
|
||||||
|
<option value="Positive">Positive</option>
|
||||||
|
<option value="Negative">Negative</option>
|
||||||
|
<option value="Neutral">Neutral</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Report_Date" class="block text-sm font-medium text-gray-700 mb-1">Report Date & Time</label>
|
||||||
|
<input type="datetime-local" id="Report_Date" name="Report_Date" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label for="Link_01" class="block text-sm font-medium text-gray-700 mb-1">Link 1</label>
|
||||||
|
<input type="url" id="Link_01" name="Link_01" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Link_02" class="block text-sm font-medium text-gray-700 mb-1">Link 2</label>
|
||||||
|
<input type="url" id="Link_02" name="Link_02" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Link_03" class="block text-sm font-medium text-gray-700 mb-1">Link 3</label>
|
||||||
|
<input type="url" id="Link_03" name="Link_03" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Link_04" class="block text-sm font-medium text-gray-700 mb-1">Link 4</label>
|
||||||
|
<input type="url" id="Link_04" name="Link_04" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="Link_05" class="block text-sm font-medium text-gray-700 mb-1">Link 5</label>
|
||||||
|
<input type="url" id="Link_05" name="Link_05" placeholder="https://..." class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none text-base" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4 border-t">
|
||||||
|
<button type="button" id="cancelButton" class="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="w-full sm:w-auto px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 text-base">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/reports.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Context, Next } from 'hono';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = process.env.POCKETBASE_URL || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authMiddleware(c: Context, next: Next) {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
if (!token || token.trim() === '') {
|
||||||
|
return c.json({ error: 'No token provided' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Set the token in auth store
|
||||||
|
// The token will be automatically included in API requests
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
|
||||||
|
// Attach PocketBase instance to context for use in routes
|
||||||
|
c.set('pb', pb);
|
||||||
|
c.set('token', token);
|
||||||
|
|
||||||
|
// If we have a model from the token, attach it
|
||||||
|
if (pb.authStore.model) {
|
||||||
|
c.set('user', pb.authStore.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
return c.json({ error: 'Authentication failed: ' + (error.message || 'Unknown error') }, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
const api = new Hono();
|
||||||
|
|
||||||
|
// Placeholder for future API routes
|
||||||
|
api.get('/health', (c) => {
|
||||||
|
return c.json({ status: 'ok', message: 'API is running' });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = process.env.POCKETBASE_URL || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
const auth = new Hono();
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
auth.post('/login', async (c) => {
|
||||||
|
try {
|
||||||
|
const { email, password } = await c.req.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return c.json({ error: 'Email and password are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
|
||||||
|
// Authenticate with PocketBase
|
||||||
|
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
token: authData.token,
|
||||||
|
user: {
|
||||||
|
id: authData.record.id,
|
||||||
|
email: authData.record.email,
|
||||||
|
username: authData.record.username,
|
||||||
|
created: authData.record.created,
|
||||||
|
updated: authData.record.updated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json(
|
||||||
|
{ error: error.message || 'Login failed' },
|
||||||
|
401
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout endpoint
|
||||||
|
auth.post('/logout', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
await pb.authStore.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ message: 'Logged out successfully' });
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json({ error: error.message || 'Logout failed' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user session
|
||||||
|
auth.get('/me', async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
|
||||||
|
if (!pb.authStore.isValid) {
|
||||||
|
return c.json({ error: 'Invalid token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = pb.authStore.model;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ error: 'User not found' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
username: user.username,
|
||||||
|
created: user.created,
|
||||||
|
updated: user.updated,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json({ error: error.message || 'Authentication failed' }, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default auth;
|
||||||
|
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = process.env.POCKETBASE_URL || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
const employees = new Hono();
|
||||||
|
|
||||||
|
// Initialize PocketBase helper
|
||||||
|
function getPocketBase(token: string): PocketBase {
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
if (token) {
|
||||||
|
// Set the auth token - this will be used for API requests
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
}
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all employee records
|
||||||
|
employees.get('/', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const page = parseInt(c.req.query('page') || '1');
|
||||||
|
const perPage = parseInt(c.req.query('perPage') || '50');
|
||||||
|
const sort = c.req.query('sort') || 'Last_Name';
|
||||||
|
|
||||||
|
console.log('Fetching employee records with token:', token ? 'Token present' : 'No token');
|
||||||
|
console.log('PocketBase URL:', POCKETBASE_URL);
|
||||||
|
|
||||||
|
const records = await pb.collection('employee_records').getList(page, perPage, {
|
||||||
|
sort: sort,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Successfully fetched', records.items.length, 'records');
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items: records.items,
|
||||||
|
page: records.page,
|
||||||
|
perPage: records.perPage,
|
||||||
|
totalItems: records.totalItems,
|
||||||
|
totalPages: records.totalPages,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching employee records:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error.message,
|
||||||
|
status: error.status,
|
||||||
|
response: error.response,
|
||||||
|
});
|
||||||
|
return c.json({
|
||||||
|
error: error.message || 'Failed to fetch employee records',
|
||||||
|
details: error.response || error
|
||||||
|
}, error.status || 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single employee record by ID
|
||||||
|
employees.get('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const record = await pb.collection('employee_records').getOne(id);
|
||||||
|
|
||||||
|
return c.json(record);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching employee record:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to fetch employee record' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new employee record
|
||||||
|
employees.post('/', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
// Extract only the fields we expect
|
||||||
|
const recordData: any = {};
|
||||||
|
if (body.id) recordData.id = body.id;
|
||||||
|
if (body.First_Name) recordData.First_Name = body.First_Name;
|
||||||
|
if (body.Last_Name) recordData.Last_Name = body.Last_Name;
|
||||||
|
if (body.Email) recordData.Email = body.Email;
|
||||||
|
if (body.Phone_Number) recordData.Phone_Number = body.Phone_Number;
|
||||||
|
if (body.Voxer_ID) recordData.Voxer_ID = body.Voxer_ID;
|
||||||
|
if (body.Type) recordData.Type = body.Type;
|
||||||
|
|
||||||
|
const record = await pb.collection('employee_records').create(recordData);
|
||||||
|
|
||||||
|
return c.json(record, 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating employee record:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to create employee record' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update an employee record
|
||||||
|
employees.patch('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
// Extract only the fields we expect
|
||||||
|
const recordData: any = {};
|
||||||
|
if (body.First_Name !== undefined) recordData.First_Name = body.First_Name;
|
||||||
|
if (body.Last_Name !== undefined) recordData.Last_Name = body.Last_Name;
|
||||||
|
if (body.Email !== undefined) recordData.Email = body.Email;
|
||||||
|
if (body.Phone_Number !== undefined) recordData.Phone_Number = body.Phone_Number;
|
||||||
|
if (body.Voxer_ID !== undefined) recordData.Voxer_ID = body.Voxer_ID;
|
||||||
|
if (body.Type !== undefined) recordData.Type = body.Type;
|
||||||
|
|
||||||
|
const record = await pb.collection('employee_records').update(id, recordData);
|
||||||
|
|
||||||
|
return c.json(record);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating employee record:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to update employee record' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an employee record
|
||||||
|
employees.delete('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
await pb.collection('employee_records').delete(id);
|
||||||
|
|
||||||
|
return c.json({ message: 'Employee record deleted successfully' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting employee record:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to delete employee record' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default employees;
|
||||||
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { authMiddleware } from '../middleware/auth';
|
||||||
|
|
||||||
|
const POCKETBASE_URL = process.env.POCKETBASE_URL || 'https://pocketbase.ccllc.pro';
|
||||||
|
|
||||||
|
const reports = new Hono();
|
||||||
|
|
||||||
|
// Initialize PocketBase helper
|
||||||
|
function getPocketBase(token: string): PocketBase {
|
||||||
|
const pb = new PocketBase(POCKETBASE_URL);
|
||||||
|
if (token) {
|
||||||
|
pb.authStore.save(token, null);
|
||||||
|
}
|
||||||
|
return pb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all employee reports
|
||||||
|
reports.get('/', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const page = parseInt(c.req.query('page') || '1');
|
||||||
|
const perPage = parseInt(c.req.query('perPage') || '50');
|
||||||
|
const sort = c.req.query('sort') || '-created';
|
||||||
|
const employeeId = c.req.query('employee'); // Filter by employee ID
|
||||||
|
|
||||||
|
const filterOptions: any = {
|
||||||
|
sort: sort,
|
||||||
|
expand: 'Employee', // Expand the Employee relation to get employee details
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add filter if employee ID is provided
|
||||||
|
if (employeeId) {
|
||||||
|
filterOptions.filter = `Employee = "${employeeId}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await pb.collection('employee_reports').getList(page, perPage, filterOptions);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
items: records.items,
|
||||||
|
page: records.page,
|
||||||
|
perPage: records.perPage,
|
||||||
|
totalItems: records.totalItems,
|
||||||
|
totalPages: records.totalPages,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching reports:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to fetch reports' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get a single employee report by ID
|
||||||
|
reports.get('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const record = await pb.collection('employee_reports').getOne(id, {
|
||||||
|
expand: 'Employee', // Expand the Employee relation to get employee details
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(record);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching report:', error);
|
||||||
|
if (error.status === 404) {
|
||||||
|
return c.json({ error: 'Report not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ error: error.message || 'Failed to fetch report' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new employee report
|
||||||
|
reports.post('/', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
// Build data object with all employee_reports fields
|
||||||
|
const data: any = {};
|
||||||
|
if (body.Report !== undefined) data.Report = body.Report;
|
||||||
|
if (body.Employee !== undefined) data.Employee = body.Employee;
|
||||||
|
if (body.Centiment !== undefined) data.Centiment = body.Centiment;
|
||||||
|
if (body.Report_Date !== undefined) data.Report_Date = body.Report_Date;
|
||||||
|
if (body.Link_01 !== undefined) data.Link_01 = body.Link_01;
|
||||||
|
if (body.Link_02 !== undefined) data.Link_02 = body.Link_02;
|
||||||
|
if (body.Link_03 !== undefined) data.Link_03 = body.Link_03;
|
||||||
|
if (body.Link_04 !== undefined) data.Link_04 = body.Link_04;
|
||||||
|
if (body.Link_05 !== undefined) data.Link_05 = body.Link_05;
|
||||||
|
|
||||||
|
const record = await pb.collection('employee_reports').create(data);
|
||||||
|
|
||||||
|
return c.json(record, 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error creating report:', error);
|
||||||
|
return c.json({ error: error.message || 'Failed to create report' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update an employee report
|
||||||
|
reports.patch('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const data: any = {};
|
||||||
|
if (body.Report !== undefined) data.Report = body.Report;
|
||||||
|
if (body.Employee !== undefined) data.Employee = body.Employee;
|
||||||
|
if (body.Centiment !== undefined) data.Centiment = body.Centiment;
|
||||||
|
if (body.Report_Date !== undefined) data.Report_Date = body.Report_Date;
|
||||||
|
if (body.Link_01 !== undefined) data.Link_01 = body.Link_01;
|
||||||
|
if (body.Link_02 !== undefined) data.Link_02 = body.Link_02;
|
||||||
|
if (body.Link_03 !== undefined) data.Link_03 = body.Link_03;
|
||||||
|
if (body.Link_04 !== undefined) data.Link_04 = body.Link_04;
|
||||||
|
if (body.Link_05 !== undefined) data.Link_05 = body.Link_05;
|
||||||
|
|
||||||
|
const record = await pb.collection('employee_reports').update(id, data);
|
||||||
|
|
||||||
|
return c.json(record);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error updating report:', error);
|
||||||
|
if (error.status === 404) {
|
||||||
|
return c.json({ error: 'Report not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ error: error.message || 'Failed to update report' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete an employee report
|
||||||
|
reports.delete('/:id', authMiddleware, async (c) => {
|
||||||
|
try {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const token = authHeader?.substring(7) || '';
|
||||||
|
const pb = getPocketBase(token);
|
||||||
|
|
||||||
|
const id = c.req.param('id');
|
||||||
|
await pb.collection('employee_reports').delete(id);
|
||||||
|
|
||||||
|
return c.json({ message: 'Report deleted successfully' });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting report:', error);
|
||||||
|
if (error.status === 404) {
|
||||||
|
return c.json({ error: 'Report not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ error: error.message || 'Failed to delete report' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default reports;
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { serveStatic } from 'hono/bun';
|
||||||
|
import authRoutes from './routes/auth';
|
||||||
|
import apiRoutes from './routes/api';
|
||||||
|
import employeeRoutes from './routes/employees';
|
||||||
|
import reportsRoutes from './routes/reports';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.route('/api/auth', authRoutes);
|
||||||
|
app.route('/api/employees', employeeRoutes);
|
||||||
|
app.route('/api/reports', reportsRoutes);
|
||||||
|
app.route('/api', apiRoutes);
|
||||||
|
|
||||||
|
// Serve static files from public directory
|
||||||
|
app.use('/*', serveStatic({ root: './public' }));
|
||||||
|
|
||||||
|
// Fallback to index.html for SPA routing
|
||||||
|
app.get('*', serveStatic({ path: './public/index.html' }));
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT || '3000');
|
||||||
|
|
||||||
|
console.log(`🚀 Server running on http://localhost:${port}`);
|
||||||
|
|
||||||
|
// Use Bun.serve for proper Bun integration
|
||||||
|
Bun.serve({
|
||||||
|
port,
|
||||||
|
fetch: app.fetch,
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user