Compare commits

..

4 Commits

Author SHA1 Message Date
eewing 241b8a0f76 fix one issues still needs work on pulling info for pocketbase 2025-11-26 13:30:27 -06:00
eewing c783836bca updated but still issues with Jobs Page api loading 2025-11-26 12:57:28 -06:00
eewing 9eb8be207d main setup created. 2025-11-26 11:22:55 -06:00
eewing 076027e8b4 readme init 2025-11-26 10:02:29 -06:00
12 changed files with 2650 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Dependencies
node_modules/
bun.lockb
# Environment variables
.env
.env.local
.env.production
# Build output
dist/
build/
# Logs
*.log
logs/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
+46
View File
@@ -1,2 +1,48 @@
# PRISM
PRISM is a construction job management system built with Bun, Hono, and PocketBase.
## Features
- **Authentication**: Login system powered by PocketBase
- **Dashboard**: Post-login dashboard with project overview
- **User Management**: Standard PocketBase users collection
- **API Integration**: Direct connection to PocketBase backend
## Getting Started
### Prerequisites
- [Bun](https://bun.sh/) runtime
### Installation
```bash
bun install
```
### Development
```bash
bun run dev
```
### Production
```bash
bun run build
bun run start
```
## Accessing the Application
- **Login Page**: Visit `http://localhost:3000` in your browser
- **Dashboard**: Available at `/dashboard.html` after authentication
- **Backend API**: `GET /health` - Health check endpoint
## PocketBase Configuration
The application connects to your PocketBase instance at `https://pocketbase.ccllc.pro` with:
- Users collection (standard PocketBase authentication)
- API collection for additional data
- Authentication rule: `id = @request.auth.id`
+28
View File
@@ -0,0 +1,28 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "prism",
"dependencies": {
"hono": "^4.0.0",
"pocketbase": "^0.21.0",
},
"devDependencies": {
"@types/node": "^20.0.0",
"bun-types": "^1.0.0",
},
},
},
"packages": {
"@types/node": ["@types/node@20.19.25", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
"pocketbase": ["pocketbase@0.21.5", "", {}, "sha512-bnI/uinnQps+ElSlzxkc4yvwuSFfKcoszDtXH/4QT2FhGq2mJVUvDlxn+rjRXVntUjPfmMG5LEPZ1eGqV6ssog=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "prism",
"version": "1.0.0",
"description": "PRISM - Construction job management system",
"main": "src/index.ts",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"build": "bun build src/index.ts",
"test": "bun test"
},
"dependencies": {
"hono": "^4.0.0",
"pocketbase": "^0.21.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"bun-types": "^1.0.0"
}
}
+158
View File
@@ -0,0 +1,158 @@
// Shared components for PRISM application
export class MenuBar {
constructor(currentPage = 'dashboard') {
this.currentPage = currentPage;
this.injectStyles();
}
injectStyles() {
// Only inject styles once
if (document.getElementById('menubar-styles')) {
return;
}
const style = document.createElement('style');
style.id = 'menubar-styles';
style.textContent = `
.header {
background: linear-gradient(135deg, var(--gruvbox-bg2) 0%, var(--gruvbox-bg1) 100%);
color: var(--gruvbox-fg0);
padding: 1.25rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
border-bottom: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--gruvbox-orange);
}
.nav-links {
display: flex;
gap: 1rem;
align-items: center;
}
.nav-link {
color: var(--gruvbox-fg1);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
}
.nav-link:hover {
background: var(--gruvbox-bg3);
color: var(--gruvbox-fg0);
}
.nav-link.active {
background: var(--gruvbox-blue);
color: var(--gruvbox-bg0);
}
.user-info {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info span {
color: var(--gruvbox-fg1);
font-weight: 500;
}
.logout-btn {
background: var(--gruvbox-red);
color: var(--gruvbox-fg0);
border: 1px solid var(--gruvbox-bg3);
padding: 0.625rem 1.25rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.logout-btn:hover {
background: var(--gruvbox-orange);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(251, 73, 52, 0.3);
}
@media (max-width: 768px) {
.header {
padding: 1rem;
flex-direction: column;
gap: 1rem;
text-align: center;
}
.nav-links {
flex-wrap: wrap;
}
}
`;
document.head.appendChild(style);
}
render(userName = 'Loading...') {
return `
<header class="header">
<h1>🏗️ PRISM</h1>
<nav class="nav-links">
<a href="/dashboard.html" class="nav-link ${this.currentPage === 'dashboard' ? 'active' : ''}">Dashboard</a>
<a href="/jobs.html" class="nav-link ${this.currentPage === 'jobs' ? 'active' : ''}">Jobs</a>
</nav>
<div class="user-info">
<span id="user-name">${userName}</span>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</header>
`;
}
updateUserName(name) {
const userNameElement = document.getElementById('user-name');
if (userNameElement) {
userNameElement.textContent = name;
}
}
}
// Utility function to initialize the menu bar
export function initMenuBar(currentPage, userName = 'Loading...') {
const menuBar = new MenuBar(currentPage);
// Insert the menu bar at the beginning of the body
const body = document.body;
const firstChild = body.firstElementChild;
// Create a temporary container for the menu HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = menuBar.render(userName).trim();
// Insert the menu bar before the first child
if (firstChild) {
body.insertBefore(tempDiv.firstElementChild, firstChild);
} else {
body.appendChild(tempDiv.firstElementChild);
}
return menuBar;
}
// Export a function to get the current page from the URL
export function getCurrentPage() {
const path = window.location.pathname;
if (path.includes('jobs.html')) {
return 'jobs';
}
return 'dashboard';
}
+383
View File
@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PRISM Dashboard - Construction Job Management</title>
<style>
:root {
--gruvbox-bg0: #282828;
--gruvbox-bg1: #3c3836;
--gruvbox-bg2: #504945;
--gruvbox-bg3: #665c54;
--gruvbox-fg0: #fbf1c7;
--gruvbox-fg1: #ebdbb2;
--gruvbox-fg2: #d5c4a1;
--gruvbox-orange: #fe8019;
--gruvbox-green: #b8bb26;
--gruvbox-blue: #83a598;
--gruvbox-purple: #d3869b;
--gruvbox-red: #fb4934;
--gruvbox-yellow: #fabd2f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Pro Display', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, var(--gruvbox-bg0) 0%, var(--gruvbox-bg2) 50%, var(--gruvbox-bg1) 100%);
min-height: 100vh;
color: var(--gruvbox-fg1);
}
.main-content {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.welcome-section {
background: var(--gruvbox-bg1);
padding: 2rem;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
margin-bottom: 2rem;
border: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
}
.welcome-section h2 {
color: var(--gruvbox-orange);
margin-bottom: 1rem;
font-size: 1.5rem;
font-weight: 700;
}
.welcome-section p {
color: var(--gruvbox-fg2);
line-height: 1.6;
font-size: 1rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.dashboard-card {
background: var(--gruvbox-bg1);
padding: 1.75rem;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
border: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
}
.dashboard-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
border-color: var(--gruvbox-blue);
}
.dashboard-card h3 {
color: var(--gruvbox-fg0);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.1rem;
font-weight: 600;
}
.dashboard-card .icon {
font-size: 1.5rem;
color: var(--gruvbox-green);
}
.dashboard-card p {
color: var(--gruvbox-fg2);
line-height: 1.6;
margin-bottom: 1rem;
}
.stat-number {
font-size: 2.5rem;
font-weight: 800;
color: var(--gruvbox-orange);
margin-bottom: 0.5rem;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.recent-activity {
background: var(--gruvbox-bg1);
padding: 1.75rem;
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
border: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
}
.recent-activity h3 {
color: var(--gruvbox-fg0);
margin-bottom: 1rem;
font-size: 1.2rem;
font-weight: 600;
}
.activity-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.25rem 0;
border-bottom: 1px solid var(--gruvbox-bg3);
transition: background 0.3s ease;
}
.activity-item:last-child {
border-bottom: none;
}
.activity-item:hover {
background: rgba(131, 165, 152, 0.05);
border-radius: 8px;
padding-left: 0.5rem;
margin-left: -0.5rem;
margin-right: -0.5rem;
}
.activity-icon {
width: 45px;
height: 45px;
background: linear-gradient(135deg, var(--gruvbox-blue) 0%, var(--gruvbox-purple) 100%);
color: var(--gruvbox-bg0);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(131, 165, 152, 0.3);
}
.activity-content {
flex: 1;
}
.activity-content h4 {
color: var(--gruvbox-fg0);
font-size: 0.95rem;
margin-bottom: 0.25rem;
font-weight: 600;
}
.activity-content p {
color: var(--gruvbox-fg2);
font-size: 0.85rem;
opacity: 0.9;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
flex-direction: column;
gap: 1rem;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid var(--gruvbox-bg3);
border-top: 4px solid var(--gruvbox-orange);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-message {
background: rgba(251, 73, 52, 0.1);
color: var(--gruvbox-red);
padding: 1.25rem;
border-radius: 8px;
margin: 1rem 0;
border: 1px solid rgba(251, 73, 52, 0.3);
backdrop-filter: blur(5px);
}
@media (max-width: 768px) {
.main-content {
padding: 1rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.welcome-section {
padding: 1.5rem;
}
.dashboard-card {
padding: 1.25rem;
}
.recent-activity {
padding: 1.25rem;
}
}
</style>
</head>
<body>
<main class="main-content">
<div id="loading" class="loading">
<div class="loading-spinner"></div>
<p>Loading dashboard...</p>
</div>
<div id="dashboard-content" style="display: none;">
<div class="welcome-section">
<h2>Welcome back!</h2>
<p>Here's your construction job management overview. Stay organized and efficient with PRISM.</p>
</div>
<div class="dashboard-grid">
<div class="dashboard-card">
<h3><span class="icon">📋</span> Active Jobs</h3>
<div class="stat-number" id="active-jobs">-</div>
<p>Currently active construction projects</p>
</div>
<div class="dashboard-card">
<h3><span class="icon">👷</span> Team Members</h3>
<div class="stat-number" id="team-members">-</div>
<p>People working on your projects</p>
</div>
<div class="dashboard-card">
<h3><span class="icon"></span> Completed This Month</h3>
<div class="stat-number" id="completed-month">-</div>
<p>Jobs finished in the last 30 days</p>
</div>
<div class="dashboard-card">
<h3><span class="icon">📊</span> Revenue</h3>
<div class="stat-number" id="revenue">$0</div>
<p>Total revenue this quarter</p>
</div>
</div>
<div class="recent-activity">
<h3>Recent Activity</h3>
<div id="activity-list">
<div class="activity-item">
<div class="activity-icon">🔄</div>
<div class="activity-content">
<h4>System Initialized</h4>
<p>Welcome to PRISM dashboard</p>
</div>
</div>
</div>
</div>
</div>
<div id="error-content" class="error-message" style="display: none;">
Unable to load dashboard. Please try refreshing the page.
</div>
</main>
<script type="module">
import PocketBase from '/pocketbase.es.mjs';
import { initMenuBar, getCurrentPage } from '/components.js';
// Initialize PocketBase
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// Check authentication and load dashboard
async function initDashboard() {
try {
// Check if user is authenticated
if (!pb.authStore.isValid) {
// Redirect to login if not authenticated
window.location.href = '/';
return;
}
// Initialize menu bar
const menuBar = initMenuBar(getCurrentPage());
const user = pb.authStore.model;
menuBar.updateUserName(user.name || user.email);
// Hide loading and show dashboard
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard-content').style.display = 'block';
// Load dashboard data
await loadDashboardData();
} catch (error) {
console.error('Dashboard initialization failed:', error.message);
showError('Failed to initialize dashboard. Please try logging in again.');
setTimeout(() => {
window.location.href = '/';
}, 3000);
}
}
async function loadDashboardData() {
try {
// This is a placeholder for actual API calls to your collections
// For now, we'll show some mock data
// Mock data - replace with actual API calls to your collections
document.getElementById('active-jobs').textContent = '12';
document.getElementById('team-members').textContent = '8';
document.getElementById('completed-month').textContent = '5';
document.getElementById('revenue').textContent = '$125,000';
// Example: Load data from your API collection
// const records = await pb.collection('api').getList(1, 50);
// Process your data here...
} catch (error) {
console.error('Error loading dashboard data:', error.message);
// Show error but don't redirect - user is authenticated
document.getElementById('error-content').style.display = 'block';
}
}
function logout() {
pb.authStore.clear();
window.location.href = '/';
}
// Make logout function globally available
window.logout = logout;
function showError(message) {
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard-content').style.display = 'none';
document.getElementById('error-content').textContent = message;
document.getElementById('error-content').style.display = 'block';
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', initDashboard);
</script>
</body>
</html>
+398
View File
@@ -0,0 +1,398 @@
import PocketBase from '/pocketbase.es.mjs';
// Initialize PocketBase
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// API key for admin operations (fetched from 'api' collection)
let API_KEY = null;
/**
* Check if user is authenticated
* @returns {boolean}
*/
export function isAuthenticated() {
return pb.authStore.isValid;
}
/**
* Get current authenticated user
* @returns {object|null}
*/
export function getCurrentUser() {
return pb.authStore.model;
}
/**
* Logout current user
*/
export function logout() {
pb.authStore.clear();
}
/**
* Fetch API key from the 'api' collection
* The key is stored in a field called 'API_KEY'
*/
export async function loadApiKey() {
// Try different collection name variations
const collectionNames = ['api', 'API', 'Api'];
for (const collectionName of collectionNames) {
try {
console.log(`Trying to load API key from collection: ${collectionName}`);
// Fetch the API key from the collection
const records = await pb.collection(collectionName).getFullList({
sort: '-created'
});
console.log(`Found ${records?.length || 0} record(s) in ${collectionName} collection`);
if (records && records.length > 0) {
const firstRecord = records[0];
console.log(`First record fields:`, Object.keys(firstRecord));
console.log(`First record data:`, firstRecord);
// Try different field name variations
// Priority: API_KEY (as shown in PocketBase admin), then other variations
API_KEY = firstRecord.API_KEY ||
firstRecord.Key ||
firstRecord.key ||
firstRecord.KEY ||
firstRecord.api_key ||
firstRecord.apiKey ||
firstRecord.ApiKey ||
null;
if (API_KEY) {
console.log(`✅ API key loaded successfully from ${collectionName} collection`);
return API_KEY; // Success, exit function
} else {
console.warn(`API key field not found in ${collectionName} collection record. Available fields:`, Object.keys(firstRecord));
}
} else {
console.warn(`No records found in ${collectionName} collection`);
}
} catch (error) {
console.warn(`Could not load from ${collectionName} collection:`, error.message);
// Try next collection name
}
}
// If we get here, no API key was found
console.warn('⚠️ API key not found in any api collection. Operations will use user authentication.');
return null;
}
/**
* Get API key (returns null if not loaded)
* @returns {string|null}
*/
export function getApiKey() {
return API_KEY;
}
/**
* Load collection schema from PocketBase
* @param {string} collectionName - Name of the collection
* @returns {Promise<Array>} Collection schema
*/
export async function loadCollectionSchema(collectionName = 'Jobs') {
// Try to fetch real schema from PocketBase
try {
// Use API key for schema access if available (bypasses collection rules)
let collection;
if (API_KEY) {
// Make request with API key in header
const response = await fetch(`${pb.baseUrl}/api/collections/${collectionName}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
collection = await response.json();
} else {
// Use standard PocketBase client (may fail with 403 if no permissions)
collection = await pb.collections.getOne(collectionName);
}
console.log('Fetched collection:', collection);
console.log('Collection schema:', collection.schema);
if (collection.schema && collection.schema.length > 0) {
// Map PocketBase schema to our format
const schema = collection.schema.map(field => ({
name: field.name,
type: field.type,
required: field.required || false,
options: field.options || {}
}));
console.log('Real collection schema loaded with', schema.length, 'fields');
console.log('Schema details:', schema.map(f => ({
name: f.name,
type: f.type,
required: f.required
})));
return schema;
} else {
throw new Error('Collection schema is empty');
}
} catch (error) {
console.warn('Could not fetch collection schema, using defaults:', error.message);
// Fallback to predefined schema
const defaultSchema = [
{ name: 'Job_Number', type: 'number' },
{ name: 'Job_Name', type: 'text' },
{ name: 'Job_Address', type: 'text' },
{ name: 'updated', type: 'date' }
];
console.log('Using predefined collection schema with', defaultSchema.length, 'fields');
return defaultSchema;
}
}
/**
* Get collection info (for debugging)
* @param {string} collectionName - Name of the collection
* @returns {Promise<object>} Collection info
*/
export async function getCollectionInfo(collectionName = 'Jobs') {
try {
let collection;
if (API_KEY) {
// Use API key for schema access
const response = await fetch(`${pb.baseUrl}/api/collections/${collectionName}`, {
headers: {
'Authorization': `Bearer ${API_KEY}`
}
});
if (response.ok) {
collection = await response.json();
} else {
throw new Error(`HTTP ${response.status}`);
}
} else {
collection = await pb.collections.getOne(collectionName);
}
return collection;
} catch (error) {
console.warn('Could not fetch collection info:', error.message);
throw error;
}
}
/**
* Load all jobs from the Jobs collection
* @returns {Promise<Array>} Array of job records
*/
export async function loadJobs() {
try {
console.log('Attempting to load jobs from collection...');
const collectionUsed = 'Jobs';
// Use getFullList to fetch all records at once (better for client-side filtering)
const records = await pb.collection(collectionUsed).getFullList({
sort: '-updated'
});
console.log(`Jobs response from collection "${collectionUsed}":`, records);
console.log('Number of jobs found:', records?.length || 0);
// Debug: Log each job's structure
if (records && records.length > 0) {
console.log('Sample job structure:', records[0]);
console.log('Job fields:', Object.keys(records[0]));
}
return records || [];
} catch (error) {
console.error('Error loading jobs:', error);
console.error('Error details:', {
message: error.message,
status: error.response?.status,
data: error.response?.data,
url: error.config?.url
});
// Handle specific error cases
if (error.response?.status === 403) {
throw new Error('Access denied to jobs collection. Please check your permissions in PocketBase.');
} else if (error.response?.status === 404) {
throw new Error('Jobs collection not found. Please create the "jobs" collection in PocketBase.');
} else {
throw new Error(`Failed to load jobs: ${error.message}`);
}
}
}
/**
* Get the next available job number
* @returns {Promise<number>} Next job number (1000-9999)
*/
export async function getNextJobNumber() {
try {
// Use getFirstListItem to get only the highest job number (most efficient)
// Sort by Job_Number descending to get the highest first
const highestJob = await pb.collection('Jobs').getFirstListItem('', {
sort: '-Job_Number'
});
const highestJobNumber = highestJob.Job_Number || 0;
const nextJobNumber = highestJobNumber + 1;
// Ensure it's within 1000-9999 range
if (nextJobNumber < 1000) {
return 1000;
} else if (nextJobNumber > 9999) {
throw new Error('Maximum job number (9999) reached');
}
return nextJobNumber;
} catch (error) {
// If no records exist or any other error, start with 1000
if (error.status === 404 || error.message?.includes('not found')) {
// No jobs exist, start with 1000
return 1000;
}
console.error('Error getting next job number:', error);
// Fallback: start with 1000 if we can't determine the next number
return 1000;
}
}
/**
* Create a new job
* @param {object} jobData - Job data object
* @returns {Promise<object>} Created job record
*/
export async function createJob(jobData) {
try {
// Check if job number already exists (optional validation)
try {
const existingJobs = await pb.collection('Jobs').getList(1, 1, {
filter: `Job_Number = ${jobData.Job_Number}`
});
if (existingJobs.items.length > 0) {
throw new Error(`Job number ${jobData.Job_Number} already exists. Please refresh to get the next available number.`);
}
} catch (checkError) {
if (checkError.message.includes('already exists')) {
throw checkError;
}
// Ignore other errors from the check - continue with creation
console.warn('Could not check for existing job number:', checkError.message);
}
// Create job in PocketBase
const createdJob = await pb.collection('Jobs').create(jobData);
console.log('Job created successfully:', createdJob);
return createdJob;
} catch (error) {
console.error('Error creating job:', error);
throw error;
}
}
/**
* Delete a job
* @param {string} jobId - Job record ID
* @returns {Promise<void>}
*/
export async function deleteJob(jobId) {
try {
await pb.collection('Jobs').delete(jobId);
} catch (error) {
console.error('Error deleting job:', error.message);
throw error;
}
}
/**
* Debug function to test PocketBase connection and permissions
*/
export async function debugPocketBase() {
console.log('=== POCKETBASE DEBUG ===');
console.log('Base URL:', pb.baseUrl);
console.log('Auth valid:', pb.authStore.isValid);
console.log('Auth model:', pb.authStore.model);
try {
// Try to get collections
const collections = await pb.collections.getFullList();
console.log('Available collections:', collections.map(c => c.name));
} catch (error) {
console.error('Cannot list collections:', error.message);
}
// Test the Jobs collection specifically
console.log('Testing Jobs collection...');
// Test read access
try {
const result = await pb.collection('Jobs').getList(1, 10);
console.log(`✅ Read access: ${result.totalItems} items found`);
if (result.items.length > 0) {
console.log('Sample item:', result.items[0]);
}
} catch (error) {
console.log('❌ Read access failed:', error.message);
}
// Test write access with dummy data
try {
const testData = { Job_Number: 9999, Job_Name: 'TEST_JOB_PLEASE_DELETE' };
const testResult = await pb.collection('Jobs').create(testData);
console.log('✅ Write access successful, created test record:', testResult);
// Clean up test record
await pb.collection('Jobs').delete(testResult.id);
console.log('✅ Test record cleaned up');
} catch (error) {
console.log('❌ Write access failed:', error.message);
if (error.response) {
console.log('Error details:', error.response.data);
}
}
console.log('=== END DEBUG ===');
}
/**
* Debug collections
*/
export async function debugCollections() {
try {
console.log('=== DEBUGGING COLLECTIONS ===');
// Try different collection names (only valid ones)
const collectionNames = ['jobs', 'Jobs'];
for (const collectionName of collectionNames) {
try {
console.log(`Trying collection: ${collectionName}`);
const records = await pb.collection(collectionName).getList(1, 10);
console.log(`${collectionName} found with ${records.totalItems} items`);
if (records.totalItems > 0) {
console.log(`${collectionName} sample item:`, records.items[0]);
}
} catch (error) {
console.log(`${collectionName} error:`, error.message);
}
}
console.log('=== END DEBUG ===');
} catch (error) {
console.warn('Debug collections failed:', error.message);
}
}
// Export PocketBase instance for advanced usage if needed
export { pb };
+403
View File
@@ -0,0 +1,403 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PRISM - Construction Job Management</title>
<style>
:root {
--gruvbox-bg0: #282828;
--gruvbox-bg1: #3c3836;
--gruvbox-bg2: #504945;
--gruvbox-bg3: #665c54;
--gruvbox-fg0: #fbf1c7;
--gruvbox-fg1: #ebdbb2;
--gruvbox-fg2: #d5c4a1;
--gruvbox-orange: #fe8019;
--gruvbox-green: #b8bb26;
--gruvbox-blue: #83a598;
--gruvbox-purple: #d3869b;
--gruvbox-red: #fb4934;
--gruvbox-yellow: #fabd2f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Pro Display', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: linear-gradient(135deg, var(--gruvbox-bg0) 0%, var(--gruvbox-bg2) 50%, var(--gruvbox-bg1) 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--gruvbox-fg1);
}
.login-container {
background: var(--gruvbox-bg1);
padding: 2rem;
border-radius: 15px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 400px;
border: 1px solid var(--gruvbox-bg3);
backdrop-filter: blur(10px);
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-header h1 {
color: var(--gruvbox-orange);
margin-bottom: 0.5rem;
font-size: 2rem;
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.login-header p {
color: var(--gruvbox-fg2);
font-size: 0.9rem;
font-weight: 400;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: var(--gruvbox-fg1);
font-weight: 600;
font-size: 0.9rem;
}
.form-group input {
width: 100%;
padding: 0.875rem;
border: 2px solid var(--gruvbox-bg3);
border-radius: 8px;
font-size: 1rem;
background: var(--gruvbox-bg2);
color: var(--gruvbox-fg1);
transition: all 0.3s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--gruvbox-blue);
box-shadow: 0 0 0 3px rgba(131, 165, 152, 0.1);
background: var(--gruvbox-bg0);
}
.form-group input::placeholder {
color: var(--gruvbox-fg2);
opacity: 0.7;
}
.login-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, var(--gruvbox-orange) 0%, var(--gruvbox-red) 100%);
color: var(--gruvbox-bg0);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(254, 128, 25, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.error-message {
background: rgba(251, 73, 52, 0.1);
color: var(--gruvbox-red);
padding: 0.875rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid rgba(251, 73, 52, 0.3);
display: none;
backdrop-filter: blur(5px);
}
.success-message {
background: rgba(184, 187, 38, 0.1);
color: var(--gruvbox-green);
padding: 0.875rem;
border-radius: 8px;
margin-bottom: 1rem;
border: 1px solid rgba(184, 187, 38, 0.3);
display: none;
backdrop-filter: blur(5px);
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #ffffff;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.user-info {
background: var(--gruvbox-bg2);
padding: 1.25rem;
border-radius: 8px;
margin-top: 1rem;
display: none;
border: 1px solid var(--gruvbox-bg3);
}
.user-info h3 {
margin-bottom: 0.5rem;
color: var(--gruvbox-green);
font-size: 1.1rem;
}
.user-info p {
margin-bottom: 0.25rem;
color: var(--gruvbox-fg2);
font-size: 0.9rem;
}
.logout-btn {
background: var(--gruvbox-red);
color: var(--gruvbox-fg0);
border: none;
padding: 0.625rem 1.25rem;
border-radius: 6px;
cursor: pointer;
margin-top: 1rem;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.3s ease;
}
.logout-btn:hover {
background: var(--gruvbox-orange);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(251, 73, 52, 0.3);
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-header">
<h1>PRISM</h1>
<p>Construction Job Management System</p>
</div>
<div id="error-message" class="error-message"></div>
<div id="success-message" class="success-message"></div>
<div id="login-form">
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" placeholder="Enter your email" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" placeholder="Enter your password" required>
</div>
<button id="login-btn" class="login-btn">
<span id="login-text">Login</span>
<span id="loading" class="loading" style="display: none;"></span>
</button>
</div>
<div id="user-info" class="user-info" style="display: none;">
<h3>Welcome!</h3>
<p id="user-details"></p>
<button class="logout-btn" onclick="logout()">Logout</button>
</div>
</div>
<script type="module">
import PocketBase from '/pocketbase.es.mjs';
// Initialize PocketBase
const pb = new PocketBase('https://pocketbase.ccllc.pro');
// Optional: Set API key for admin operations (if needed)
// pb.authStore.save('itlbbkeok6lxtvxvic8dgpugj2962h', null);
// Check if user is already authenticated
if (pb.authStore.isValid) {
showUserInfo(pb.authStore.model);
}
// Test connection on page load
testConnection();
async function testConnection() {
try {
// Test basic connectivity (silent)
await pb.health.check();
} catch (error) {
// Only show user-facing error for real connection issues
if (error.message && !error.message.includes('autocancelled') && !error.message.includes('aborted')) {
showError('Unable to connect to server. Please check your internet connection.');
}
}
}
async function login() {
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const loginBtn = document.getElementById('login-btn');
const loginText = document.getElementById('login-text');
const loading = document.getElementById('loading');
if (!email || !password) {
showError('Please fill in all fields');
return;
}
// Show loading state
loginBtn.disabled = true;
loginText.style.display = 'none';
loading.style.display = 'inline-block';
try {
const authData = await pb.collection('users').authWithPassword(email, password);
showSuccess('Login successful! Redirecting to dashboard...');
// Clear form
document.getElementById('email').value = '';
document.getElementById('password').value = '';
// Redirect to dashboard after a short delay
setTimeout(() => {
window.location.href = '/dashboard.html';
}, 1500);
} catch (error) {
// Log error for debugging (can be removed in production)
console.error('Login failed:', error.message);
// Provide more specific error messages
let errorMessage = 'Login failed. Please try again.';
if (error.response) {
// Server responded with error
const status = error.response.status;
const data = error.response.data;
if (status === 400) {
errorMessage = 'Invalid email or password. Please check your credentials.';
} else if (status === 401) {
errorMessage = 'Authentication failed. Please check your email and password.';
} else if (status === 403) {
errorMessage = 'Access denied. Your account may be disabled.';
} else if (status === 404) {
errorMessage = 'User not found. Please check your email address.';
} else if (status >= 500) {
errorMessage = 'Server error. Please try again later.';
} else if (data && data.message) {
errorMessage = data.message;
}
} else if (error.code === 'NETWORK_ERROR' || error.message?.includes('fetch')) {
errorMessage = 'Network error. Please check your internet connection.';
} else if (error.message) {
errorMessage = error.message;
}
showError(errorMessage);
} finally {
// Reset loading state
loginBtn.disabled = false;
loginText.style.display = 'inline';
loading.style.display = 'none';
}
}
function logout() {
pb.authStore.clear();
document.getElementById('user-info').style.display = 'none';
document.getElementById('login-form').style.display = 'block';
showSuccess('Logged out successfully');
}
// Make logout function globally available
window.logout = logout;
function showUserInfo(user) {
document.getElementById('login-form').style.display = 'none';
document.getElementById('user-info').style.display = 'block';
const userDetails = document.getElementById('user-details');
userDetails.innerHTML = `
<strong>Name:</strong> ${user.name || 'N/A'}<br>
<strong>Email:</strong> ${user.email}<br>
<strong>ID:</strong> ${user.id}<br>
<strong>Created:</strong> ${new Date(user.created).toLocaleDateString()}
`;
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = message;
errorDiv.style.display = 'block';
document.getElementById('success-message').style.display = 'none';
}
function showSuccess(message) {
const successDiv = document.getElementById('success-message');
successDiv.textContent = message;
successDiv.style.display = 'block';
document.getElementById('error-message').style.display = 'none';
}
// Allow Enter key to submit form
document.getElementById('password').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
login();
}
});
// Handle login button click
document.getElementById('login-btn').addEventListener('click', function(e) {
e.preventDefault();
login();
});
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
testConnection();
});
</script>
</body>
</html>
+1122
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
import { Hono } from 'hono'
import { serveStatic } from 'hono/bun'
import PocketBase from 'pocketbase/cjs'
const app = new Hono()
// Initialize PocketBase for server-side testing
const pb = new PocketBase('https://pocketbase.ccllc.pro')
// Serve static files from public directory
app.use('/public/*', serveStatic({ root: './public' }))
app.use('/dashboard.html', serveStatic({ path: './public/dashboard.html' }))
app.use('/jobs.html', serveStatic({ path: './public/jobs.html' }))
app.use('/components.js', serveStatic({ path: './public/components.js' }))
app.use('/db.js', serveStatic({ path: './public/db.js' }))
app.use('/pocketbase.es.mjs', serveStatic({ path: './public/pocketbase.es.mjs' }))
app.use('/pocketbase.es.mjs.map', serveStatic({ path: './public/pocketbase.es.mjs.map' }))
// Serve the main login page
app.get('/', serveStatic({ path: './public/index.html' }))
app.get('/health', (c) => {
return c.json({ status: 'ok', service: 'PRISM API' })
})
app.get('/test-pocketbase', async (c) => {
try {
const health = await pb.health.check()
return c.json({
status: 'ok',
pocketbase: health,
timestamp: new Date().toISOString()
})
} catch (error) {
return c.json({
status: 'error',
error: error.message,
timestamp: new Date().toISOString()
}, 500)
}
})
export default app