This commit is contained in:
2025-12-14 13:31:29 -06:00
parent eefd867ab5
commit a6672f08fa
19 changed files with 3252 additions and 1 deletions
+24
View File
@@ -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
+84 -1
View File
@@ -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.
+32
View File
@@ -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=="],
}
}
+20
View File
@@ -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"
}
}
+133
View File
@@ -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>
+329
View File
@@ -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>
+63
View File
@@ -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>
+74
View File
@@ -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';
}
});
+179
View File
@@ -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();
});
+985
View File
@@ -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;
+632
View File
@@ -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;
}
+181
View File
@@ -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>
+47
View File
@@ -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);
}
}
+11
View File
@@ -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;
+96
View File
@@ -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;
+149
View File
@@ -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;
+161
View File
@@ -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;
+31
View File
@@ -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,
});
+21
View File
@@ -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"]
}