Compare commits
148 Commits
main
...
d4f16d344d
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f16d344d | |||
| 9ea67b626b | |||
| 5369c799d0 | |||
| 1ac0c09ae2 | |||
| ca618a7109 | |||
| a01f941891 | |||
| e29f703d16 | |||
| b16cc688b1 | |||
| 7520e9d422 | |||
| b1f64d37bb | |||
| f3f0dca3e5 | |||
| e35f9b3e7b | |||
| a8db92731f | |||
| 97a12a3bcd | |||
| 661f57cace | |||
| 10c1c4d69e | |||
| afd0c26fbb | |||
| 2b27583c28 | |||
| 8b11dc5083 | |||
| 75b8bfd84f | |||
| 1fb435eb87 | |||
| c0d357efdd | |||
| abad26b68a | |||
| b75fd6f872 | |||
| b1a1735a12 | |||
| 628ace4ad5 | |||
| ad6338ecae | |||
| 1bc9dc2dd5 | |||
| 3292435e68 | |||
| 22503e1ce0 | |||
| 05ec244107 | |||
| 77f4c80a79 | |||
| 5abacdc8e1 | |||
| 5b794a59bc | |||
| 9dd208d168 | |||
| 02f31cb444 | |||
| 60b79fa360 | |||
| c9b7355f3c | |||
| eb41814c24 | |||
| e6fab5ba31 | |||
| 99853bb755 | |||
| 9b84c6b9e8 | |||
| 6ac5ac9dda | |||
| fae7615818 | |||
| acb7117c7d | |||
| 1d8ee8b0ab | |||
| d3fa4df621 | |||
| a1f1b33e44 | |||
| 7f63dc1df6 | |||
| ac77a9d259 | |||
| 38b9401b04 | |||
| 9b1f42c4ec | |||
| 6b205f36bb | |||
| be1bab103f | |||
| c2dbc9d777 | |||
| 3f87ea16f2 | |||
| 056198ff16 | |||
| 5b1a284fc3 | |||
| 23b3c0e9e8 | |||
| eec0c0a281 | |||
| cc242d4e10 | |||
| b6ceac6e38 | |||
| 42668862fd | |||
| af28be8112 | |||
| 27247477c9 | |||
| bd0345df1a | |||
| a1b9c05673 | |||
| d9e559982a | |||
| 0e237a9549 | |||
| f1e7c2d7aa | |||
| 7ec4bdf620 | |||
| ec5b60d478 | |||
| ac5fedb61a | |||
| ce3c53b4a8 | |||
| cdfc37c273 | |||
| 1288fe1cf8 | |||
| 33c5466d77 | |||
| a6ef325813 | |||
| 952caf10d1 | |||
| e19172d2bb | |||
| 80a53d5d15 | |||
| 5011f80fc4 | |||
| 9357c2e0b9 | |||
| 119b03a7ba | |||
| f4b30c0faf | |||
| 79238dd643 | |||
| 31736ccc78 | |||
| 50760ab099 | |||
| a59dc83678 | |||
| 769fc73898 | |||
| 6ab87c7396 | |||
| a4e607bfe1 | |||
| e589d6667f | |||
| fc5f0fd39a | |||
| e68b185aeb | |||
| 5aac63dfde | |||
| 8a0baa02c3 | |||
| fd87b66b06 | |||
| 96333ecced | |||
| 0f451555d3 | |||
| 5193442e10 | |||
| 94b46be15b | |||
| c39746f4f6 | |||
| 671a4490d7 | |||
| e091e29a80 | |||
| 142b6490cc | |||
| abfd174f85 | |||
| 8964a9b29b | |||
| 1a49919000 | |||
| 0bb0b07429 | |||
| e4a4261a0e | |||
| 029b091b10 | |||
| cf8ff874da | |||
| c75982818c | |||
| e5e72b597a | |||
| 06599c844a | |||
| e5955a31fd | |||
| 43622f8e65 | |||
| 7a2611f031 | |||
| 249b1e21c3 | |||
| 551a0ea71a | |||
| adc348b61b | |||
| 49f97a9939 | |||
| 99f3d657ab | |||
| cc6d217476 | |||
| 47d730f192 | |||
| c1d49689da | |||
| 95ef139843 | |||
| 2735d46552 | |||
| 0dd988730f | |||
| 50adc50a24 | |||
| 639a7b7eab | |||
| 07b4af5f24 | |||
| 6fc2e6703b | |||
| 764f6d1100 | |||
| 225d16c1c9 | |||
| aada481c0a | |||
| c767e65819 | |||
| 8e35821344 | |||
|
|
747a68832e | ||
|
|
e828aa660b | ||
|
|
9b6307eabe | ||
|
|
490994d323 | ||
|
|
b5120657a9 | ||
|
|
5228ed3fc0 | ||
|
|
51d37fc65a | ||
|
|
92f458e59b | ||
|
|
33ea8de17e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,3 +45,8 @@ next-env.d.ts
|
||||
|
||||
# kosz
|
||||
/kosz
|
||||
|
||||
# uploads
|
||||
/public/uploads
|
||||
|
||||
/backups
|
||||
@@ -1,379 +0,0 @@
|
||||
# Audit Logging Implementation
|
||||
|
||||
This document describes the audit logging system implemented for the panel application. The system provides comprehensive tracking of user actions and system events for security, compliance, and monitoring purposes.
|
||||
|
||||
## Features
|
||||
|
||||
- **Comprehensive Action Tracking**: Logs all CRUD operations on projects, tasks, contracts, notes, and user management
|
||||
- **Authentication Events**: Tracks login attempts, successes, and failures
|
||||
- **Detailed Context**: Captures IP addresses, user agents, and request details
|
||||
- **Flexible Filtering**: Query logs by user, action, resource type, date range, and more
|
||||
- **Statistics Dashboard**: Provides insights into system usage patterns
|
||||
- **Role-based Access**: Only admins and project managers can view audit logs
|
||||
- **Performance Optimized**: Uses database indexes for efficient querying
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **Audit Log Utility** (`src/lib/auditLog.js`)
|
||||
|
||||
- Core logging functions
|
||||
- Query and statistics functions
|
||||
- Action and resource type constants
|
||||
|
||||
2. **API Endpoints** (`src/app/api/audit-logs/`)
|
||||
|
||||
- `/api/audit-logs` - Query audit logs with filtering
|
||||
- `/api/audit-logs/stats` - Get audit log statistics
|
||||
|
||||
3. **UI Components** (`src/components/AuditLogViewer.js`)
|
||||
|
||||
- Interactive audit log viewer
|
||||
- Advanced filtering interface
|
||||
- Statistics dashboard
|
||||
|
||||
4. **Admin Pages** (`src/app/admin/audit-logs/`)
|
||||
- Admin interface for viewing audit logs
|
||||
- Role-based access control
|
||||
|
||||
### Database Schema
|
||||
|
||||
The audit logs are stored in the `audit_logs` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT, -- User who performed the action
|
||||
action TEXT NOT NULL, -- Action performed (see AUDIT_ACTIONS)
|
||||
resource_type TEXT, -- Type of resource affected
|
||||
resource_id TEXT, -- ID of the affected resource
|
||||
ip_address TEXT, -- IP address of the user
|
||||
user_agent TEXT, -- Browser/client information
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
details TEXT, -- Additional details (JSON)
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Logging
|
||||
|
||||
```javascript
|
||||
import { logAuditEvent, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog";
|
||||
|
||||
// Log a simple action
|
||||
logAuditEvent({
|
||||
action: AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
userId: "user123",
|
||||
resourceType: RESOURCE_TYPES.PROJECT,
|
||||
resourceId: "proj-456",
|
||||
ipAddress: req.ip,
|
||||
userAgent: req.headers["user-agent"],
|
||||
details: {
|
||||
project_name: "New Project",
|
||||
project_number: "NP-001",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### API Route Integration
|
||||
|
||||
```javascript
|
||||
import { logApiAction, AUDIT_ACTIONS, RESOURCE_TYPES } from "@/lib/auditLog";
|
||||
|
||||
export async function POST(req) {
|
||||
const data = await req.json();
|
||||
|
||||
// Perform the operation
|
||||
const result = createProject(data);
|
||||
|
||||
// Log the action
|
||||
logApiAction(
|
||||
req,
|
||||
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
result.id.toString(),
|
||||
req.session,
|
||||
{ projectData: data }
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, id: result.id });
|
||||
}
|
||||
```
|
||||
|
||||
### Querying Audit Logs
|
||||
|
||||
```javascript
|
||||
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||
|
||||
// Get recent logs
|
||||
const recentLogs = getAuditLogs({
|
||||
limit: 50,
|
||||
orderBy: "timestamp",
|
||||
orderDirection: "DESC",
|
||||
});
|
||||
|
||||
// Get logs for a specific user
|
||||
const userLogs = getAuditLogs({
|
||||
userId: "user123",
|
||||
startDate: "2025-01-01T00:00:00Z",
|
||||
endDate: "2025-12-31T23:59:59Z",
|
||||
});
|
||||
|
||||
// Get statistics
|
||||
const stats = getAuditLogStats({
|
||||
startDate: "2025-01-01T00:00:00Z",
|
||||
endDate: "2025-12-31T23:59:59Z",
|
||||
});
|
||||
```
|
||||
|
||||
## Available Actions
|
||||
|
||||
### Authentication Actions
|
||||
|
||||
- `login` - Successful user login
|
||||
- `logout` - User logout
|
||||
- `login_failed` - Failed login attempt
|
||||
|
||||
### Project Actions
|
||||
|
||||
- `project_create` - Project creation
|
||||
- `project_update` - Project modification
|
||||
- `project_delete` - Project deletion
|
||||
- `project_view` - Project viewing
|
||||
|
||||
### Task Actions
|
||||
|
||||
- `task_create` - Task creation
|
||||
- `task_update` - Task modification
|
||||
- `task_delete` - Task deletion
|
||||
- `task_status_change` - Task status modification
|
||||
|
||||
### Project Task Actions
|
||||
|
||||
- `project_task_create` - Project task assignment
|
||||
- `project_task_update` - Project task modification
|
||||
- `project_task_delete` - Project task removal
|
||||
- `project_task_status_change` - Project task status change
|
||||
|
||||
### Contract Actions
|
||||
|
||||
- `contract_create` - Contract creation
|
||||
- `contract_update` - Contract modification
|
||||
- `contract_delete` - Contract deletion
|
||||
|
||||
### Note Actions
|
||||
|
||||
- `note_create` - Note creation
|
||||
- `note_update` - Note modification
|
||||
- `note_delete` - Note deletion
|
||||
|
||||
### Admin Actions
|
||||
|
||||
- `user_create` - User account creation
|
||||
- `user_update` - User account modification
|
||||
- `user_delete` - User account deletion
|
||||
- `user_role_change` - User role modification
|
||||
|
||||
### System Actions
|
||||
|
||||
- `data_export` - Data export operations
|
||||
- `bulk_operation` - Bulk data operations
|
||||
|
||||
## Resource Types
|
||||
|
||||
- `project` - Project resources
|
||||
- `task` - Task templates
|
||||
- `project_task` - Project-specific tasks
|
||||
- `contract` - Contracts
|
||||
- `note` - Notes and comments
|
||||
- `user` - User accounts
|
||||
- `session` - Authentication sessions
|
||||
- `system` - System-level operations
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /api/audit-logs
|
||||
|
||||
Query audit logs with optional filtering.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `userId` - Filter by user ID
|
||||
- `action` - Filter by action type
|
||||
- `resourceType` - Filter by resource type
|
||||
- `resourceId` - Filter by resource ID
|
||||
- `startDate` - Filter from date (ISO string)
|
||||
- `endDate` - Filter to date (ISO string)
|
||||
- `limit` - Maximum results (default: 100)
|
||||
- `offset` - Results offset (default: 0)
|
||||
- `orderBy` - Order by field (default: timestamp)
|
||||
- `orderDirection` - ASC or DESC (default: DESC)
|
||||
- `includeStats` - Include statistics (true/false)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": "user123",
|
||||
"user_name": "John Doe",
|
||||
"user_email": "john@example.com",
|
||||
"action": "project_create",
|
||||
"resource_type": "project",
|
||||
"resource_id": "proj-456",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0...",
|
||||
"timestamp": "2025-07-09T10:30:00Z",
|
||||
"details": {
|
||||
"project_name": "New Project",
|
||||
"project_number": "NP-001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total": 150,
|
||||
"actionBreakdown": [...],
|
||||
"userBreakdown": [...],
|
||||
"resourceBreakdown": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/audit-logs/stats
|
||||
|
||||
Get audit log statistics.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
- `startDate` - Filter from date (ISO string)
|
||||
- `endDate` - Filter to date (ISO string)
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"total": 150,
|
||||
"actionBreakdown": [
|
||||
{ "action": "project_view", "count": 45 },
|
||||
{ "action": "login", "count": 23 }
|
||||
],
|
||||
"userBreakdown": [
|
||||
{ "user_id": "user123", "user_name": "John Doe", "count": 67 }
|
||||
],
|
||||
"resourceBreakdown": [{ "resource_type": "project", "count": 89 }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Access Control
|
||||
|
||||
Audit logs are restricted to users with the following roles:
|
||||
|
||||
- `admin` - Full access to all audit logs
|
||||
- `project_manager` - Full access to all audit logs
|
||||
|
||||
Other users cannot access audit logs.
|
||||
|
||||
## Testing
|
||||
|
||||
Run the audit logging test script:
|
||||
|
||||
```bash
|
||||
node test-audit-logging.mjs
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Create sample audit events
|
||||
2. Test querying and filtering
|
||||
3. Verify statistics generation
|
||||
4. Test date range filtering
|
||||
|
||||
## Integration Status
|
||||
|
||||
The audit logging system has been integrated into the following API routes:
|
||||
|
||||
✅ **Authentication** (`src/lib/auth.js`)
|
||||
|
||||
- Login success/failure tracking
|
||||
- Account lockout logging
|
||||
|
||||
✅ **Projects** (`src/app/api/projects/`)
|
||||
|
||||
- Project CRUD operations
|
||||
- List view access
|
||||
|
||||
✅ **Notes** (`src/app/api/notes/`)
|
||||
|
||||
- Note creation, updates, and deletion
|
||||
|
||||
🔄 **Pending Integration:**
|
||||
|
||||
- Tasks API
|
||||
- Project Tasks API
|
||||
- Contracts API
|
||||
- User management API
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Database indexes are created on frequently queried fields
|
||||
- Large result sets are paginated
|
||||
- Statistics queries are optimized for common use cases
|
||||
- Failed operations are logged to prevent data loss
|
||||
|
||||
## Security Features
|
||||
|
||||
- IP address tracking for forensic analysis
|
||||
- User agent logging for client identification
|
||||
- Failed authentication attempt tracking
|
||||
- Detailed change logging for sensitive operations
|
||||
- Role-based access control for audit log viewing
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Log Retention
|
||||
|
||||
Consider implementing log retention policies:
|
||||
|
||||
```sql
|
||||
-- Delete audit logs older than 1 year
|
||||
DELETE FROM audit_logs
|
||||
WHERE timestamp < datetime('now', '-1 year');
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
|
||||
Monitor audit log growth and performance:
|
||||
|
||||
```sql
|
||||
-- Check audit log table size
|
||||
SELECT COUNT(*) as total_logs,
|
||||
MIN(timestamp) as oldest_log,
|
||||
MAX(timestamp) as newest_log
|
||||
FROM audit_logs;
|
||||
|
||||
-- Check most active users
|
||||
SELECT user_id, COUNT(*) as activity_count
|
||||
FROM audit_logs
|
||||
WHERE timestamp > datetime('now', '-30 days')
|
||||
GROUP BY user_id
|
||||
ORDER BY activity_count DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Real-time audit log streaming
|
||||
- Advanced analytics and reporting
|
||||
- Integration with external SIEM systems
|
||||
- Automatic anomaly detection
|
||||
- Compliance reporting templates
|
||||
- Log export functionality
|
||||
@@ -1,971 +0,0 @@
|
||||
# Authorization Implementation Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
This document outlines the implementation strategy for adding authentication and authorization to the Project Management Panel - a Next.js 15 application with SQLite database.
|
||||
|
||||
## Current State Analysis (Updated: June 25, 2025)
|
||||
|
||||
### ✅ What We Have Implemented
|
||||
|
||||
- **Framework**: Next.js 15 with App Router
|
||||
- **Database**: SQLite with better-sqlite3
|
||||
- **Authentication**: NextAuth.js v5 with credentials provider
|
||||
- **User Management**: Complete user CRUD operations with bcrypt password hashing
|
||||
- **Database Schema**: Users table with roles, audit logs, sessions
|
||||
- **API Protection**: Middleware system with role-based access control
|
||||
- **Session Management**: JWT-based sessions with 30-day expiration
|
||||
- **Security Features**: Account lockout, failed login tracking, password validation
|
||||
- **UI Components**: Authentication provider, navigation with user context
|
||||
- **Auth Pages**: Sign-in page implemented
|
||||
|
||||
### ✅ What's Protected
|
||||
|
||||
- **API Routes**: All major endpoints (projects, contracts, tasks, notes) are protected
|
||||
- **Role Hierarchy**: admin > project_manager > user > read_only
|
||||
- **Navigation**: Role-based menu items (admin sees user management)
|
||||
- **Session Security**: Automatic session management and validation
|
||||
|
||||
### 🔄 Partially Implemented
|
||||
|
||||
- **Auth Pages**: Sign-in exists, missing sign-out and error pages
|
||||
- **User Interface**: Basic auth integration, could use more polish
|
||||
- **Admin Features**: User management backend exists, UI needs completion
|
||||
- **Audit Logging**: Database schema exists, not fully integrated
|
||||
|
||||
### ❌ Still Missing
|
||||
|
||||
- Complete user management UI for admins
|
||||
- Password reset functionality
|
||||
- Rate limiting implementation
|
||||
- Enhanced input validation schemas
|
||||
- CSRF protection
|
||||
- Security headers middleware
|
||||
- Comprehensive error handling
|
||||
- Email notifications
|
||||
|
||||
## Recommended Implementation Strategy
|
||||
|
||||
### 1. Authentication Solution: NextAuth.js
|
||||
|
||||
**Why NextAuth.js?**
|
||||
|
||||
- ✅ Native Next.js 15 App Router support
|
||||
- ✅ Database session management
|
||||
- ✅ Built-in security features (CSRF, JWT handling)
|
||||
- ✅ Flexible provider system
|
||||
- ✅ SQLite adapter available
|
||||
|
||||
### 2. Role-Based Access Control (RBAC)
|
||||
|
||||
**Proposed User Roles:**
|
||||
|
||||
| Role | Permissions | Use Case |
|
||||
| ------------------- | --------------------------------------- | ----------------------- |
|
||||
| **Admin** | Full system access, user management | System administrators |
|
||||
| **Project Manager** | Manage all projects/tasks, view reports | Team leads, supervisors |
|
||||
| **User** | View/edit assigned projects/tasks | Regular employees |
|
||||
| **Read-only** | View-only access to data | Clients, stakeholders |
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### ✅ Phase 1: Foundation Setup - COMPLETED
|
||||
|
||||
#### 1.1 Dependencies - ✅ INSTALLED
|
||||
|
||||
- NextAuth.js v5 (beta)
|
||||
- bcryptjs for password hashing
|
||||
- Zod for validation
|
||||
- Better-sqlite3 adapter compatibility
|
||||
|
||||
#### 1.2 Environment Configuration - ✅ COMPLETED
|
||||
|
||||
- `.env.local` configured with NEXTAUTH_SECRET and NEXTAUTH_URL
|
||||
- Database URL configuration
|
||||
- Development environment setup
|
||||
|
||||
#### 1.3 Database Schema - ✅ IMPLEMENTED
|
||||
|
||||
- Users table with roles and security features
|
||||
- Sessions table for NextAuth.js
|
||||
- Audit logs table for security tracking
|
||||
- Proper indexes for performance
|
||||
|
||||
#### 1.4 Initial Admin User - ✅ COMPLETED
|
||||
|
||||
- `scripts/create-admin.js` script available
|
||||
- Default admin user: admin@localhost.com / admin123456
|
||||
|
||||
### ✅ Phase 2: Authentication Core - COMPLETED
|
||||
|
||||
#### 2.1 NextAuth.js Configuration - ✅ IMPLEMENTED
|
||||
|
||||
- **File**: `src/lib/auth.js`
|
||||
- Credentials provider with email/password
|
||||
- JWT session strategy with 30-day expiration
|
||||
- Account lockout after 5 failed attempts (15-minute lockout)
|
||||
- Password verification with bcrypt
|
||||
- Failed login attempt tracking
|
||||
- Session callbacks for role management
|
||||
|
||||
#### 2.2 API Route Handlers - ✅ IMPLEMENTED
|
||||
|
||||
- **File**: `src/app/api/auth/[...nextauth]/route.js`
|
||||
- NextAuth.js handlers properly configured
|
||||
|
||||
#### 2.3 User Management System - ✅ IMPLEMENTED
|
||||
|
||||
- **File**: `src/lib/userManagement.js`
|
||||
- Complete CRUD operations for users
|
||||
- Password hashing and validation
|
||||
- Role management functions
|
||||
- User lookup by ID and email
|
||||
|
||||
### ✅ Phase 3: Authorization Middleware - COMPLETED
|
||||
|
||||
#### 3.1 API Protection Middleware - ✅ IMPLEMENTED
|
||||
|
||||
- **File**: `src/lib/middleware/auth.js`
|
||||
- `withAuth()` function for protecting routes
|
||||
- Role hierarchy enforcement (admin=4, project_manager=3, user=2, read_only=1)
|
||||
- Helper functions: `withReadAuth`, `withUserAuth`, `withAdminAuth`, `withManagerAuth`
|
||||
- Proper error handling and status codes
|
||||
|
||||
#### 3.2 Protected API Routes - ✅ IMPLEMENTED
|
||||
|
||||
Example in `src/app/api/projects/route.js`:
|
||||
|
||||
- GET requests require read_only access
|
||||
- POST requests require user access
|
||||
- All major API endpoints are protected
|
||||
|
||||
#### 3.3 Session Provider - ✅ IMPLEMENTED
|
||||
|
||||
- **File**: `src/components/auth/AuthProvider.js`
|
||||
- NextAuth SessionProvider wrapper
|
||||
- Integrated into root layout
|
||||
|
||||
### 🔄 Phase 4: User Interface - PARTIALLY COMPLETED
|
||||
|
||||
#### 4.1 Authentication Pages - 🔄 PARTIAL
|
||||
|
||||
- ✅ **Sign-in page**: `src/app/auth/signin/page.js` - Complete with form validation
|
||||
- ❌ **Sign-out page**: Missing
|
||||
- 🔄 **Error page**: `src/app/auth/error/page.js` - Basic implementation
|
||||
- ❌ **Unauthorized page**: Missing
|
||||
|
||||
#### 4.2 Navigation Updates - ✅ COMPLETED
|
||||
|
||||
- **File**: `src/components/ui/Navigation.js`
|
||||
- User session integration with useSession
|
||||
- Role-based menu items (admin sees user management)
|
||||
- Sign-out functionality
|
||||
- Conditional rendering based on auth status
|
||||
|
||||
#### 4.3 User Management Interface - ❌ MISSING
|
||||
|
||||
- Backend exists in userManagement.js
|
||||
- Admin UI for user CRUD operations needed
|
||||
- Role assignment interface needed
|
||||
|
||||
### ❌ Phase 5: Security Enhancements - NOT STARTED
|
||||
|
||||
#### 5.1 Input Validation Schemas - ❌ MISSING
|
||||
|
||||
- Zod schemas for API endpoints
|
||||
- Request validation middleware
|
||||
|
||||
#### 5.2 Rate Limiting - ❌ MISSING
|
||||
|
||||
- Rate limiting middleware
|
||||
- IP-based request tracking
|
||||
|
||||
#### 5.3 Security Headers - ❌ MISSING
|
||||
|
||||
- CSRF protection
|
||||
- Security headers middleware
|
||||
- Content Security Policy
|
||||
|
||||
```javascript
|
||||
import NextAuth from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { BetterSQLite3Adapter } from "@auth/better-sqlite3-adapter";
|
||||
import db from "./db.js";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Invalid email format"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
adapter: BetterSQLite3Adapter(db),
|
||||
session: {
|
||||
strategy: "database",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
updateAge: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
try {
|
||||
// Validate input
|
||||
const validatedFields = loginSchema.parse(credentials);
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT id, email, name, password_hash, role, is_active,
|
||||
failed_login_attempts, locked_until
|
||||
FROM users
|
||||
WHERE email = ? AND is_active = 1
|
||||
`
|
||||
)
|
||||
.get(validatedFields.email);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Check if account is locked
|
||||
if (user.locked_until && new Date(user.locked_until) > new Date()) {
|
||||
throw new Error("Account temporarily locked");
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
validatedFields.password,
|
||||
user.password_hash
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Increment failed attempts
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = failed_login_attempts + 1,
|
||||
locked_until = CASE
|
||||
WHEN failed_login_attempts >= 4
|
||||
THEN datetime('now', '+15 minutes')
|
||||
ELSE locked_until
|
||||
END
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Reset failed attempts and update last login
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE users
|
||||
SET failed_login_attempts = 0,
|
||||
locked_until = NULL,
|
||||
last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`
|
||||
).run(user.id);
|
||||
|
||||
// Log successful login
|
||||
logAuditEvent(user.id, "LOGIN_SUCCESS", "user", user.id, req);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user, account }) {
|
||||
if (user) {
|
||||
token.role = user.role;
|
||||
token.userId = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token, user }) {
|
||||
if (token) {
|
||||
session.user.id = token.userId || token.sub;
|
||||
session.user.role = token.role || user?.role;
|
||||
}
|
||||
return session;
|
||||
},
|
||||
async signIn({ user, account, profile, email, credentials }) {
|
||||
// Additional sign-in logic if needed
|
||||
return true;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/signin",
|
||||
signOut: "/auth/signout",
|
||||
error: "/auth/error",
|
||||
},
|
||||
events: {
|
||||
async signOut({ session, token }) {
|
||||
if (session?.user?.id) {
|
||||
logAuditEvent(session.user.id, "LOGOUT", "user", session.user.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Audit logging helper
|
||||
function logAuditEvent(userId, action, resourceType, resourceId, req = null) {
|
||||
try {
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
userId,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId,
|
||||
req?.ip || "unknown",
|
||||
req?.headers?.["user-agent"] || "unknown"
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Audit log error:", error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 API Route Handlers
|
||||
|
||||
Create `src/app/api/auth/[...nextauth]/route.js`:
|
||||
|
||||
```javascript
|
||||
import { handlers } from "@/lib/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
```
|
||||
|
||||
### Phase 3: Authorization Middleware
|
||||
|
||||
#### 3.1 API Protection Middleware
|
||||
|
||||
Create `src/lib/middleware/auth.js`:
|
||||
|
||||
```javascript
|
||||
import { auth } from "@/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
// Role hierarchy for permission checking
|
||||
const ROLE_HIERARCHY = {
|
||||
admin: 4,
|
||||
project_manager: 3,
|
||||
user: 2,
|
||||
read_only: 1,
|
||||
};
|
||||
|
||||
export function withAuth(handler, options = {}) {
|
||||
return async (req, context) => {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication required" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user account is active
|
||||
const user = db
|
||||
.prepare("SELECT is_active FROM users WHERE id = ?")
|
||||
.get(session.user.id);
|
||||
if (!user?.is_active) {
|
||||
return NextResponse.json(
|
||||
{ error: "Account deactivated" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check role-based permissions
|
||||
if (
|
||||
options.requiredRole &&
|
||||
!hasPermission(session.user.role, options.requiredRole)
|
||||
) {
|
||||
logAuditEvent(
|
||||
session.user.id,
|
||||
"ACCESS_DENIED",
|
||||
options.resource || "api",
|
||||
req.url
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check resource-specific permissions
|
||||
if (options.checkResourceAccess) {
|
||||
const hasAccess = await options.checkResourceAccess(
|
||||
session.user,
|
||||
context.params
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Access denied to this resource" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate request body if schema provided
|
||||
if (
|
||||
options.bodySchema &&
|
||||
(req.method === "POST" ||
|
||||
req.method === "PUT" ||
|
||||
req.method === "PATCH")
|
||||
) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
options.bodySchema.parse(body);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data", details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add user info to request
|
||||
req.user = session.user;
|
||||
req.session = session;
|
||||
|
||||
// Call the original handler
|
||||
return await handler(req, context);
|
||||
} catch (error) {
|
||||
console.error("Auth middleware error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hasPermission(userRole, requiredRole) {
|
||||
return ROLE_HIERARCHY[userRole] >= ROLE_HIERARCHY[requiredRole];
|
||||
}
|
||||
|
||||
// Helper for read-only operations
|
||||
export function withReadAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: "read_only" });
|
||||
}
|
||||
|
||||
// Helper for user-level operations
|
||||
export function withUserAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: "user" });
|
||||
}
|
||||
|
||||
// Helper for project manager operations
|
||||
export function withManagerAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: "project_manager" });
|
||||
}
|
||||
|
||||
// Helper for admin operations
|
||||
export function withAdminAuth(handler) {
|
||||
return withAuth(handler, { requiredRole: "admin" });
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 Client-Side Route Protection
|
||||
|
||||
Create `src/components/auth/ProtectedRoute.js`:
|
||||
|
||||
```javascript
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ProtectedRoute({
|
||||
children,
|
||||
requiredRole = null,
|
||||
fallback = null,
|
||||
}) {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return; // Still loading
|
||||
|
||||
if (!session) {
|
||||
router.push("/auth/signin");
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiredRole && !hasPermission(session.user.role, requiredRole)) {
|
||||
router.push("/unauthorized");
|
||||
return;
|
||||
}
|
||||
}, [session, status, router, requiredRole]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">Loading...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return fallback || <div>Redirecting to login...</div>;
|
||||
}
|
||||
|
||||
if (requiredRole && !hasPermission(session.user.role, requiredRole)) {
|
||||
return fallback || <div>Access denied</div>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function hasPermission(userRole, requiredRole) {
|
||||
const roleHierarchy = {
|
||||
admin: 4,
|
||||
project_manager: 3,
|
||||
user: 2,
|
||||
read_only: 1,
|
||||
};
|
||||
|
||||
return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: User Interface Components
|
||||
|
||||
#### 4.1 Authentication Pages
|
||||
|
||||
Pages to create:
|
||||
|
||||
- `src/app/auth/signin/page.js` - Login form
|
||||
- `src/app/auth/signout/page.js` - Logout confirmation
|
||||
- `src/app/auth/error/page.js` - Error handling
|
||||
- `src/app/unauthorized/page.js` - Access denied page
|
||||
|
||||
#### 4.2 Navigation Updates
|
||||
|
||||
Update `src/components/ui/Navigation.js` to include:
|
||||
|
||||
- Login/logout buttons
|
||||
- User info display
|
||||
- Role-based menu items
|
||||
|
||||
#### 4.3 User Management Interface
|
||||
|
||||
For admin users:
|
||||
|
||||
- User listing and management
|
||||
- Role assignment
|
||||
- Account activation/deactivation
|
||||
|
||||
### Phase 5: Security Enhancements
|
||||
|
||||
#### 5.1 Input Validation Schemas
|
||||
|
||||
Create `src/lib/schemas/` with Zod schemas for all API endpoints:
|
||||
|
||||
```javascript
|
||||
// src/lib/schemas/project.js
|
||||
import { z } from "zod";
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
contract_id: z.number().int().positive(),
|
||||
project_name: z.string().min(1).max(255),
|
||||
project_number: z.string().min(1).max(50),
|
||||
address: z.string().optional(),
|
||||
// ... other fields
|
||||
});
|
||||
|
||||
export const updateProjectSchema = createProjectSchema.partial();
|
||||
```
|
||||
|
||||
#### 5.2 Rate Limiting
|
||||
|
||||
Implement rate limiting for sensitive endpoints:
|
||||
|
||||
```javascript
|
||||
// src/lib/middleware/rateLimit.js
|
||||
const attempts = new Map();
|
||||
|
||||
export function withRateLimit(
|
||||
handler,
|
||||
options = { maxAttempts: 5, windowMs: 15 * 60 * 1000 }
|
||||
) {
|
||||
return async (req, context) => {
|
||||
const key = req.ip || "unknown";
|
||||
const now = Date.now();
|
||||
const window = attempts.get(key) || {
|
||||
count: 0,
|
||||
resetTime: now + options.windowMs,
|
||||
};
|
||||
|
||||
if (now > window.resetTime) {
|
||||
window.count = 1;
|
||||
window.resetTime = now + options.windowMs;
|
||||
} else {
|
||||
window.count++;
|
||||
}
|
||||
|
||||
attempts.set(key, window);
|
||||
|
||||
if (window.count > options.maxAttempts) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
return handler(req, context);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Checklist (Updated Status)
|
||||
|
||||
### ✅ Phase 1: Foundation - COMPLETED
|
||||
|
||||
- [x] Install dependencies (NextAuth.js v5, bcryptjs, zod)
|
||||
- [x] Create environment configuration (.env.local)
|
||||
- [x] Extend database schema (users, sessions, audit_logs)
|
||||
- [x] Create initial admin user script
|
||||
|
||||
### ✅ Phase 2: Authentication - COMPLETED
|
||||
|
||||
- [x] Configure NextAuth.js with credentials provider
|
||||
- [x] Create API route handlers (/api/auth/[...nextauth])
|
||||
- [x] Implement user management system
|
||||
- [x] Test login/logout functionality
|
||||
|
||||
### ✅ Phase 3: Authorization - COMPLETED
|
||||
|
||||
- [x] Implement API middleware (withAuth, role hierarchy)
|
||||
- [x] Protect existing API routes (projects, contracts, tasks, notes)
|
||||
- [x] Create role-based helper functions
|
||||
- [x] Integrate session provider in app layout
|
||||
|
||||
### 🔄 Phase 4: User Interface - IN PROGRESS
|
||||
|
||||
- [x] Create sign-in page with form validation
|
||||
- [x] Update navigation component with auth integration
|
||||
- [x] Add role-based menu items
|
||||
- [ ] Create sign-out confirmation page
|
||||
- [ ] Create error handling page
|
||||
- [ ] Create unauthorized access page
|
||||
- [ ] Build admin user management interface
|
||||
|
||||
### ❌ Phase 5: Security Enhancements - NOT STARTED
|
||||
|
||||
- [ ] Add input validation schemas to all endpoints
|
||||
- [ ] Implement rate limiting for sensitive operations
|
||||
- [ ] Add comprehensive audit logging
|
||||
- [ ] Create security headers middleware
|
||||
- [ ] Implement CSRF protection
|
||||
- [ ] Add password reset functionality
|
||||
|
||||
## Current Working Features
|
||||
|
||||
### 🔐 Authentication System
|
||||
|
||||
- **Login/Logout**: Fully functional with NextAuth.js
|
||||
- **Session Management**: JWT-based with 30-day expiration
|
||||
- **Password Security**: bcrypt hashing with salt rounds
|
||||
- **Account Lockout**: 5 failed attempts = 15-minute lockout
|
||||
- **Role System**: 4-tier hierarchy (admin, project_manager, user, read_only)
|
||||
|
||||
### 🛡️ Authorization System
|
||||
|
||||
- **API Protection**: All major endpoints require authentication
|
||||
- **Role-Based Access**: Different permission levels per endpoint
|
||||
- **Middleware**: Clean abstraction with helper functions
|
||||
- **Session Validation**: Automatic session verification
|
||||
|
||||
### 📱 User Interface
|
||||
|
||||
- **Navigation**: Context-aware with user info and sign-out
|
||||
- **Auth Pages**: Professional sign-in form with error handling
|
||||
- **Role Integration**: Admin users see additional menu items
|
||||
- **Responsive**: Works across device sizes
|
||||
|
||||
### 🗄️ Database Security
|
||||
|
||||
- **User Management**: Complete CRUD with proper validation
|
||||
- **Audit Schema**: Ready for comprehensive logging
|
||||
- **Indexes**: Optimized for performance
|
||||
- **Constraints**: Role validation and data integrity
|
||||
|
||||
## Next Priority Tasks
|
||||
|
||||
1. **Complete Auth UI** (High Priority)
|
||||
|
||||
- Sign-out confirmation page
|
||||
- Unauthorized access page
|
||||
- Enhanced error handling
|
||||
|
||||
2. **Admin User Management** (High Priority)
|
||||
|
||||
- User listing interface
|
||||
- Create/edit user forms
|
||||
- Role assignment controls
|
||||
|
||||
3. **Security Enhancements** (Medium Priority)
|
||||
|
||||
- Input validation schemas
|
||||
- Rate limiting middleware
|
||||
- Comprehensive audit logging
|
||||
|
||||
4. **Password Management** (Medium Priority)
|
||||
- Password reset functionality
|
||||
- Password strength requirements
|
||||
- Password change interface
|
||||
|
||||
## User Tracking in Projects - NEW FEATURE ✅
|
||||
|
||||
### 📊 Project User Management Implementation
|
||||
|
||||
We've successfully implemented comprehensive user tracking for projects:
|
||||
|
||||
#### Database Schema Updates ✅
|
||||
|
||||
- **created_by**: Tracks who created the project (user ID)
|
||||
- **assigned_to**: Tracks who is assigned to work on the project (user ID)
|
||||
- **created_at**: Timestamp when project was created
|
||||
- **updated_at**: Timestamp when project was last modified
|
||||
- **Indexes**: Performance optimized with proper foreign key indexes
|
||||
|
||||
#### API Enhancements ✅
|
||||
|
||||
- **Enhanced Queries**: Projects now include user names and emails via JOIN operations
|
||||
- **User Assignment**: New `/api/projects/users` endpoint for user management
|
||||
- **Query Filters**: Support for filtering projects by assigned user or creator
|
||||
- **User Context**: Create/update operations automatically capture authenticated user ID
|
||||
|
||||
#### UI Components ✅
|
||||
|
||||
- **Project Form**: User assignment dropdown in create/edit forms
|
||||
- **Project Listing**: "Created By" and "Assigned To" columns in project table
|
||||
- **User Selection**: Dropdown populated with active users for assignment
|
||||
|
||||
#### New Query Functions ✅
|
||||
|
||||
- `getAllUsersForAssignment()`: Get active users for assignment dropdown
|
||||
- `getProjectsByAssignedUser(userId)`: Filter projects by assignee
|
||||
- `getProjectsByCreator(userId)`: Filter projects by creator
|
||||
- `updateProjectAssignment(projectId, userId)`: Update project assignment
|
||||
|
||||
#### Security Integration ✅
|
||||
|
||||
- **Authentication Required**: All user operations require valid session
|
||||
- **Role-Based Access**: User assignment respects role hierarchy
|
||||
- **Audit Ready**: Infrastructure prepared for comprehensive user action logging
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Creating Projects with User Tracking
|
||||
|
||||
```javascript
|
||||
// Projects are automatically assigned to the authenticated user as creator
|
||||
POST /api/projects
|
||||
{
|
||||
"project_name": "New Project",
|
||||
"assigned_to": "user-id-here", // Optional assignment
|
||||
// ... other project data
|
||||
}
|
||||
```
|
||||
|
||||
#### Filtering Projects by User
|
||||
|
||||
```javascript
|
||||
// Get projects assigned to specific user
|
||||
GET /api/projects?assigned_to=user-id
|
||||
|
||||
// Get projects created by specific user
|
||||
GET /api/projects?created_by=user-id
|
||||
```
|
||||
|
||||
#### Updating Project Assignment
|
||||
|
||||
```javascript
|
||||
POST /api/projects/users
|
||||
{
|
||||
"projectId": 123,
|
||||
"assignedToUserId": "new-user-id"
|
||||
}
|
||||
```
|
||||
|
||||
## Project Tasks User Tracking - NEW FEATURE ✅
|
||||
|
||||
### 📋 Task User Management Implementation
|
||||
|
||||
We've also implemented comprehensive user tracking for project tasks:
|
||||
|
||||
#### Database Schema Updates ✅
|
||||
|
||||
- **created_by**: Tracks who created the task (user ID)
|
||||
- **assigned_to**: Tracks who is assigned to work on the task (user ID)
|
||||
- **created_at**: Timestamp when task was created
|
||||
- **updated_at**: Timestamp when task was last modified
|
||||
- **Indexes**: Performance optimized with proper foreign key indexes
|
||||
|
||||
#### API Enhancements ✅
|
||||
|
||||
- **Enhanced Queries**: Tasks now include user names and emails via JOIN operations
|
||||
- **User Assignment**: New `/api/project-tasks/users` endpoint for user management
|
||||
- **Query Filters**: Support for filtering tasks by assigned user or creator
|
||||
- **User Context**: Create/update operations automatically capture authenticated user ID
|
||||
|
||||
#### UI Components ✅
|
||||
|
||||
- **Task Form**: User assignment dropdown in create task forms
|
||||
- **Task Listing**: "Created By" and "Assigned To" columns in task table
|
||||
- **User Selection**: Dropdown populated with active users for assignment
|
||||
|
||||
#### New Task Query Functions ✅
|
||||
|
||||
- `getAllUsersForTaskAssignment()`: Get active users for assignment dropdown
|
||||
- `getProjectTasksByAssignedUser(userId)`: Filter tasks by assignee
|
||||
- `getProjectTasksByCreator(userId)`: Filter tasks by creator
|
||||
- `updateProjectTaskAssignment(taskId, userId)`: Update task assignment
|
||||
|
||||
#### Task Creation Behavior ✅
|
||||
|
||||
- **Auto-assignment**: Tasks are automatically assigned to the authenticated user as creator
|
||||
- **Optional Assignment**: Users can assign tasks to other team members during creation
|
||||
- **Creator Tracking**: All tasks track who created them for accountability
|
||||
|
||||
### Task Usage Examples
|
||||
|
||||
#### Creating Tasks with User Tracking
|
||||
|
||||
```javascript
|
||||
// Tasks are automatically assigned to the authenticated user as creator
|
||||
POST /api/project-tasks
|
||||
{
|
||||
"project_id": 123,
|
||||
"task_template_id": 1, // or custom_task_name for custom tasks
|
||||
"assigned_to": "user-id-here", // Optional, defaults to creator
|
||||
"priority": "high"
|
||||
}
|
||||
```
|
||||
|
||||
#### Filtering Tasks by User
|
||||
|
||||
```javascript
|
||||
// Get tasks assigned to specific user
|
||||
GET /api/project-tasks?assigned_to=user-id
|
||||
|
||||
// Get tasks created by specific user
|
||||
GET /api/project-tasks?created_by=user-id
|
||||
```
|
||||
|
||||
#### Updating Task Assignment
|
||||
|
||||
```javascript
|
||||
POST /api/project-tasks/users
|
||||
{
|
||||
"taskId": 456,
|
||||
"assignedToUserId": "new-user-id"
|
||||
}
|
||||
```
|
||||
|
||||
### Next Enhancements
|
||||
|
||||
1. **Dashboard Views** (Recommended)
|
||||
|
||||
- "My Projects" dashboard showing assigned projects
|
||||
- Project creation history per user
|
||||
- Workload distribution reports
|
||||
|
||||
2. **Advanced Filtering** (Future)
|
||||
|
||||
- Multi-user assignment support
|
||||
- Team-based project assignments
|
||||
- Role-based project visibility
|
||||
|
||||
3. **Notifications** (Future)
|
||||
- Email alerts on project assignment
|
||||
- Deadline reminders for assigned users
|
||||
- Status change notifications
|
||||
|
||||
## Notes User Tracking - NEW FEATURE ✅
|
||||
|
||||
### 📝 Notes User Management Implementation
|
||||
|
||||
We've also implemented comprehensive user tracking for all notes (both project notes and task notes):
|
||||
|
||||
#### Database Schema Updates ✅
|
||||
|
||||
- **created_by**: Tracks who created the note (user ID)
|
||||
- **is_system**: Distinguishes between user notes and system-generated notes
|
||||
- **Enhanced queries**: Notes now include user names and emails via JOIN operations
|
||||
- **Indexes**: Performance optimized with proper indexes for user lookups
|
||||
|
||||
#### API Enhancements ✅
|
||||
|
||||
- **User Context**: All note creation operations automatically capture authenticated user ID
|
||||
- **System Notes**: Automatic system notes (task status changes) track who made the change
|
||||
- **User Information**: Note retrieval includes creator name and email for display
|
||||
|
||||
#### UI Components ✅
|
||||
|
||||
- **Project Notes**: Display creator name and email in project note listings
|
||||
- **Task Notes**: Show who added each note with user badges and timestamps
|
||||
- **System Notes**: Distinguished from user notes with special styling and "System" badge
|
||||
- **User Attribution**: Clear indication of who created each note and when
|
||||
|
||||
#### New Note Query Functions ✅
|
||||
|
||||
- `getAllNotesWithUsers()`: Get all notes with user and project/task context
|
||||
- `getNotesByCreator(userId)`: Filter notes by creator for user activity tracking
|
||||
- Enhanced `getNotesByProjectId()` and `getNotesByTaskId()` with user information
|
||||
|
||||
#### Automatic User Tracking ✅
|
||||
|
||||
- **Note Creation**: All new notes automatically record who created them
|
||||
- **System Notes**: Task status changes generate system notes attributed to the user who made the change
|
||||
- **Audit Trail**: Complete history of who added what notes and when
|
||||
|
||||
### Notes Usage Examples
|
||||
|
||||
#### Project Notes with User Tracking
|
||||
|
||||
- Notes display creator name in a blue badge next to the timestamp
|
||||
- Form automatically associates notes with the authenticated user
|
||||
- Clear visual distinction between different note authors
|
||||
|
||||
#### Task Notes with User Tracking
|
||||
|
||||
- User notes show creator name in a gray badge
|
||||
- System notes show "System" badge but also track the user who triggered the action
|
||||
- Full audit trail of task status changes and who made them
|
||||
|
||||
#### System Note Generation
|
||||
|
||||
```javascript
|
||||
// When a user changes a task status, a system note is automatically created:
|
||||
// "Status changed from 'pending' to 'in_progress'" - attributed to the user who made the change
|
||||
```
|
||||
|
||||
### Benefits
|
||||
|
||||
1. **Accountability**: Full audit trail of who added what notes
|
||||
2. **Context**: Know who to contact for clarification on specific notes
|
||||
3. **History**: Track communication and decisions made by team members
|
||||
4. **System Integration**: Automatic notes for system actions still maintain user attribution
|
||||
5. **User Experience**: Clear visual indicators of note authors improve team collaboration
|
||||
330
DOCUMENTATION_AUDIT.md
Normal file
330
DOCUMENTATION_AUDIT.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# Documentation Audit & Recommendations
|
||||
|
||||
**Date**: January 16, 2026
|
||||
**Status**: Comprehensive review of all markdown documentation
|
||||
|
||||
---
|
||||
|
||||
## 📋 Summary
|
||||
|
||||
| Status | Count | Files |
|
||||
|--------|-------|-------|
|
||||
| ✅ **Keep & Use** | 5 | Core documentation files |
|
||||
| 🔄 **Update Required** | 3 | Outdated but valuable |
|
||||
| ⚠️ **Archive** | 2 | Historical reference only |
|
||||
| ❌ **Delete** | 2 | Obsolete/redundant |
|
||||
|
||||
---
|
||||
|
||||
## ✅ Files to KEEP (Production Documentation)
|
||||
|
||||
### 1. **README.md** ✅
|
||||
- **Status**: ✅ Recently updated (comprehensive)
|
||||
- **Action**: KEEP - Primary project documentation
|
||||
- **Quality**: Excellent - Complete with all features, API docs, deployment guide
|
||||
- **Last Updated**: January 16, 2026
|
||||
|
||||
### 2. **ROADMAP.md** ✅
|
||||
- **Status**: ✅ Recently updated (restructured)
|
||||
- **Action**: KEEP - Development planning document
|
||||
- **Quality**: Excellent - Clear phases, priorities, realistic timelines
|
||||
- **Last Updated**: January 16, 2026
|
||||
|
||||
### 3. **docs/MAP_LAYERS.md** ✅
|
||||
- **Status**: ✅ Up-to-date and accurate
|
||||
- **Action**: KEEP - Technical reference for map configuration
|
||||
- **Quality**: Good - Explains WMTS/WMS layer setup
|
||||
- **Value**: Referenced in README, needed for customization
|
||||
|
||||
### 4. **uploads/README.md** ✅
|
||||
- **Status**: ✅ Simple but useful
|
||||
- **Action**: KEEP - Directory structure explanation
|
||||
- **Quality**: Basic but sufficient
|
||||
- **Value**: Helps understand file organization
|
||||
|
||||
### 5. **CONTACTS_SYSTEM_README.md** ✅
|
||||
- **Status**: ✅ Accurate and comprehensive
|
||||
- **Action**: KEEP - Feature documentation
|
||||
- **Quality**: Excellent - Complete guide for contacts system
|
||||
- **Value**: Standalone feature documentation
|
||||
- **Recommendation**: Could be moved to `docs/` folder for better organization
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Files to UPDATE
|
||||
|
||||
### 6. **DOCX_TEMPLATES_README.md** 🔄
|
||||
- **Status**: 🔄 Good content but could be enhanced
|
||||
- **Action**: UPDATE - Add more examples and troubleshooting
|
||||
- **Quality**: Good - Lists all available variables
|
||||
- **Issues**:
|
||||
- Missing some newer variables
|
||||
- Could use more example templates
|
||||
- No troubleshooting section
|
||||
- **Recommendation**:
|
||||
```markdown
|
||||
- Add section on common errors
|
||||
- Include full example template
|
||||
- Document custom data fields better
|
||||
- Add screenshots of example documents
|
||||
```
|
||||
|
||||
### 7. **RADICALE_SYNC_README.md** 🔄
|
||||
- **Status**: 🔄 Mostly accurate but incomplete
|
||||
- **Action**: UPDATE - Add current implementation details
|
||||
- **Quality**: Good - Clear setup instructions
|
||||
- **Issues**:
|
||||
- Async implementation details could be clearer
|
||||
- Missing error handling documentation
|
||||
- No troubleshooting guide
|
||||
- **Recommendation**:
|
||||
```markdown
|
||||
- Add troubleshooting section (connection errors, auth failures)
|
||||
- Document sync status/logs
|
||||
- Add manual sync endpoint documentation
|
||||
- Include example VCard output
|
||||
```
|
||||
|
||||
### 8. **route_planning_readme.md** 🔄
|
||||
- **Status**: 🔄 Technical but could be better integrated
|
||||
- **Action**: UPDATE - Modernize and integrate with main docs
|
||||
- **Quality**: Good - Comprehensive route planning guide
|
||||
- **Issues**:
|
||||
- Not referenced in main README
|
||||
- Setup instructions could be clearer
|
||||
- Missing UI screenshots
|
||||
- **Recommendation**:
|
||||
```markdown
|
||||
- Add link from README.md to this guide
|
||||
- Update with current UI state
|
||||
- Add screenshots of route planning in action
|
||||
- Document any recent API changes
|
||||
- Consider moving to docs/ROUTE_PLANNING.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Files to ARCHIVE (Historical Reference)
|
||||
|
||||
### 9. **DEPLOYMENT_GUIDE_TEMPLATE.md** ⚠️
|
||||
- **Status**: ⚠️ Duplicate content with README
|
||||
- **Action**: ARCHIVE or DELETE
|
||||
- **Quality**: Good - Comprehensive deployment guide
|
||||
- **Issues**:
|
||||
- 411 lines of deployment instructions
|
||||
- Most content now covered in README.md
|
||||
- Some instructions are generic (not project-specific)
|
||||
- **Recommendation**:
|
||||
- **Option 1**: Move to `docs/archive/DEPLOYMENT_DETAILED.md` for reference
|
||||
- **Option 2**: Delete (README deployment section is sufficient)
|
||||
- **Decision**: ARCHIVE - May be useful for detailed deployment scenarios
|
||||
|
||||
### 10. **DOCKER_GIT_DEPLOYMENT.md** ⚠️
|
||||
- **Status**: ⚠️ Overlaps with README and DEPLOYMENT_GUIDE
|
||||
- **Action**: ARCHIVE or DELETE
|
||||
- **Quality**: Good - Specific to git-based deployment
|
||||
- **Issues**:
|
||||
- Content duplicated in README
|
||||
- Some instructions outdated
|
||||
- 205 lines when README covers this in ~30 lines
|
||||
- **Recommendation**:
|
||||
- **Option 1**: Merge unique content into README
|
||||
- **Option 2**: Archive as `docs/archive/GIT_DEPLOYMENT_DETAILED.md`
|
||||
- **Decision**: ARCHIVE - Provides more detail than README for complex deployments
|
||||
|
||||
---
|
||||
|
||||
## ❌ Files to DELETE (Obsolete)
|
||||
|
||||
### 11. **CLEANUP_PLAN.md** ❌
|
||||
- **Status**: ❌ Obsolete - Lists files for deletion
|
||||
- **Action**: DELETE after review
|
||||
- **Quality**: N/A - Planning document
|
||||
- **Reason**:
|
||||
- Lists debug files, test scripts, old migrations
|
||||
- Most listed files should be deleted or are already gone
|
||||
- This is a temporary planning document
|
||||
- Once cleanup is done, this file is no longer needed
|
||||
- **Recommendation**:
|
||||
```bash
|
||||
# Review the files it lists, clean them up, then delete this file
|
||||
# Most files listed are safe to delete
|
||||
```
|
||||
|
||||
### 12. **files-to-delete.md** ❌
|
||||
- **Status**: ❌ Duplicate of CLEANUP_PLAN.md
|
||||
- **Action**: DELETE
|
||||
- **Quality**: N/A - Planning document
|
||||
- **Reason**:
|
||||
- Same purpose as CLEANUP_PLAN.md
|
||||
- Temporary planning document
|
||||
- No longer needed after cleanup
|
||||
- **Recommendation**: DELETE immediately (redundant with CLEANUP_PLAN.md)
|
||||
|
||||
---
|
||||
|
||||
## 📁 Recommended Documentation Structure
|
||||
|
||||
### Current Structure (Flat)
|
||||
```
|
||||
panel/
|
||||
├── README.md
|
||||
├── ROADMAP.md
|
||||
├── CONTACTS_SYSTEM_README.md
|
||||
├── DOCX_TEMPLATES_README.md
|
||||
├── RADICALE_SYNC_README.md
|
||||
├── route_planning_readme.md
|
||||
├── DEPLOYMENT_GUIDE_TEMPLATE.md
|
||||
├── DOCKER_GIT_DEPLOYMENT.md
|
||||
├── CLEANUP_PLAN.md ❌
|
||||
├── files-to-delete.md ❌
|
||||
├── docs/
|
||||
│ └── MAP_LAYERS.md
|
||||
└── uploads/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### Recommended Structure (Organized)
|
||||
```
|
||||
panel/
|
||||
├── README.md ✅ (Main documentation)
|
||||
├── ROADMAP.md ✅ (Development planning)
|
||||
├── docs/
|
||||
│ ├── features/
|
||||
│ │ ├── CONTACTS_SYSTEM.md 🔄 (renamed from CONTACTS_SYSTEM_README.md)
|
||||
│ │ ├── DOCX_TEMPLATES.md 🔄 (renamed, updated)
|
||||
│ │ ├── RADICALE_SYNC.md 🔄 (renamed, updated)
|
||||
│ │ ├── ROUTE_PLANNING.md 🔄 (renamed from route_planning_readme.md)
|
||||
│ │ └── MAP_LAYERS.md ✅ (already in docs/)
|
||||
│ ├── deployment/
|
||||
│ │ └── ADVANCED_DEPLOYMENT.md ⚠️ (merged from DEPLOYMENT_GUIDE + DOCKER_GIT)
|
||||
│ └── archive/ (optional)
|
||||
│ └── [old deployment guides] ⚠️
|
||||
└── uploads/
|
||||
└── README.md ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Action Plan
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
|
||||
1. **DELETE Obsolete Files**
|
||||
```bash
|
||||
rm CLEANUP_PLAN.md
|
||||
rm files-to-delete.md
|
||||
```
|
||||
|
||||
2. **Create docs/ Structure**
|
||||
```bash
|
||||
mkdir -p docs/features
|
||||
mkdir -p docs/deployment
|
||||
mkdir -p docs/archive
|
||||
```
|
||||
|
||||
3. **Move & Rename Files**
|
||||
```bash
|
||||
# Move feature docs
|
||||
mv CONTACTS_SYSTEM_README.md docs/features/CONTACTS_SYSTEM.md
|
||||
mv DOCX_TEMPLATES_README.md docs/features/DOCX_TEMPLATES.md
|
||||
mv RADICALE_SYNC_README.md docs/features/RADICALE_SYNC.md
|
||||
mv route_planning_readme.md docs/features/ROUTE_PLANNING.md
|
||||
|
||||
# Archive deployment guides (optional)
|
||||
mv DEPLOYMENT_GUIDE_TEMPLATE.md docs/archive/
|
||||
mv DOCKER_GIT_DEPLOYMENT.md docs/archive/
|
||||
```
|
||||
|
||||
4. **Update README.md**
|
||||
- Add "Documentation" section with links to all feature docs
|
||||
- Reference docs/features/ for detailed guides
|
||||
|
||||
### Short-term Updates (Next 2 Weeks)
|
||||
|
||||
1. **Update DOCX_TEMPLATES.md**
|
||||
- Add troubleshooting section
|
||||
- Include full example template
|
||||
- Add screenshots
|
||||
|
||||
2. **Update RADICALE_SYNC.md**
|
||||
- Add troubleshooting guide
|
||||
- Document error handling
|
||||
- Add sync status monitoring
|
||||
|
||||
3. **Update ROUTE_PLANNING.md**
|
||||
- Modernize content
|
||||
- Add UI screenshots
|
||||
- Update API references
|
||||
|
||||
4. **Create Documentation Index**
|
||||
- Add docs/README.md with index of all documentation
|
||||
- Link from main README
|
||||
|
||||
---
|
||||
|
||||
## 📊 Documentation Quality Metrics
|
||||
|
||||
| Metric | Current | Target |
|
||||
|--------|---------|--------|
|
||||
| **Core Docs Complete** | 2/2 (100%) | ✅ |
|
||||
| **Feature Docs Updated** | 1/5 (20%) | 5/5 (100%) |
|
||||
| **Organized Structure** | No | Yes |
|
||||
| **Screenshots/Examples** | Few | All guides |
|
||||
| **Troubleshooting Sections** | 0 | All guides |
|
||||
| **Cross-references** | Some | Complete |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Files Status Summary
|
||||
|
||||
### ✅ KEEP AS-IS (5 files)
|
||||
1. README.md - Main documentation ✅
|
||||
2. ROADMAP.md - Development roadmap ✅
|
||||
3. docs/MAP_LAYERS.md - Map configuration ✅
|
||||
4. uploads/README.md - Upload directory info ✅
|
||||
5. CONTACTS_SYSTEM_README.md - Contacts guide ✅
|
||||
|
||||
### 🔄 UPDATE & REORGANIZE (3 files)
|
||||
6. DOCX_TEMPLATES_README.md → docs/features/DOCX_TEMPLATES.md 🔄
|
||||
7. RADICALE_SYNC_README.md → docs/features/RADICALE_SYNC.md 🔄
|
||||
8. route_planning_readme.md → docs/features/ROUTE_PLANNING.md 🔄
|
||||
|
||||
### ⚠️ ARCHIVE (2 files)
|
||||
9. DEPLOYMENT_GUIDE_TEMPLATE.md → docs/archive/ ⚠️
|
||||
10. DOCKER_GIT_DEPLOYMENT.md → docs/archive/ ⚠️
|
||||
|
||||
### ❌ DELETE (2 files)
|
||||
11. CLEANUP_PLAN.md ❌
|
||||
12. files-to-delete.md ❌
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Best Practices for Future Documentation
|
||||
|
||||
1. **Location**:
|
||||
- Core docs in root (README, ROADMAP)
|
||||
- Feature docs in `docs/features/`
|
||||
- Deployment docs in `docs/deployment/`
|
||||
- Archive old docs in `docs/archive/`
|
||||
|
||||
2. **Naming**:
|
||||
- Use UPPER_CASE.md for main docs
|
||||
- Use descriptive names (FEATURE_NAME.md)
|
||||
- Avoid "readme" suffix (implied)
|
||||
|
||||
3. **Content**:
|
||||
- Include troubleshooting section
|
||||
- Add screenshots/examples
|
||||
- Keep updated with code changes
|
||||
- Link to related docs
|
||||
|
||||
4. **Maintenance**:
|
||||
- Review quarterly
|
||||
- Update on major features
|
||||
- Archive obsolete docs (don't delete immediately)
|
||||
- Keep changelog in ROADMAP.md
|
||||
|
||||
---
|
||||
|
||||
**Recommendation**: Proceed with cleanup and reorganization to improve documentation discoverability and maintainability.
|
||||
@@ -1,152 +0,0 @@
|
||||
# ✅ Dropdown Consolidation - COMPLETED
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
The project management interface has been successfully updated to eliminate redundant status displays by consolidating status badges and dropdowns into unified interactive components.
|
||||
|
||||
## ✅ Components Successfully Updated
|
||||
|
||||
### Task Status Dropdowns:
|
||||
|
||||
- **ProjectTasksSection.js** → TaskStatusDropdownSimple ✅
|
||||
- **Tasks page** (`/tasks`) → TaskStatusDropdownSimple ✅
|
||||
- **ProjectTasksDashboard.js** → TaskStatusDropdownSimple ✅
|
||||
- **Main Dashboard** (`/`) → TaskStatusDropdownSimple ✅ (read-only mode)
|
||||
|
||||
### Status Configurations:
|
||||
|
||||
#### Task Statuses:
|
||||
|
||||
- `pending` → Warning (yellow)
|
||||
- `in_progress` → Primary (blue)
|
||||
- `completed` → Success (green)
|
||||
- `cancelled` → Danger (red)
|
||||
|
||||
#### Project Statuses:
|
||||
|
||||
- `registered` → Secondary (gray)
|
||||
- `in_progress_design` → Primary (blue)
|
||||
- `in_progress_construction` → Primary (blue)
|
||||
- `fulfilled` → Success (green)
|
||||
|
||||
## 🎯 Key Features Implemented
|
||||
|
||||
### Unified Interface:
|
||||
|
||||
- Single component serves as both status display and edit interface
|
||||
- Click to expand dropdown with available status options
|
||||
- Visual feedback with arrow rotation and hover effects
|
||||
- Loading states during API updates
|
||||
|
||||
### Debug Features (Current):
|
||||
|
||||
- Red borders around dropdowns for visibility testing
|
||||
- Yellow debug headers showing component type
|
||||
- Console logging for click events and API calls
|
||||
- Semi-transparent backdrop for easy identification
|
||||
|
||||
### Z-Index Solution:
|
||||
|
||||
- Dropdown: `z-[9999]` (maximum priority)
|
||||
- Backdrop: `z-[9998]` (behind dropdown)
|
||||
|
||||
## 🧪 Testing Instructions
|
||||
|
||||
### 1. Access Test Pages:
|
||||
|
||||
```
|
||||
http://localhost:3000/test-dropdowns # Isolated component testing
|
||||
http://localhost:3000/projects # Project list with status dropdowns
|
||||
http://localhost:3000/tasks # Task list with status dropdowns
|
||||
http://localhost:3000/ # Main dashboard
|
||||
```
|
||||
|
||||
### 2. Standalone HTML Tests:
|
||||
|
||||
```
|
||||
test-dropdown-comprehensive.html # Complete functionality test
|
||||
test-dropdown.html # Basic dropdown structure test
|
||||
```
|
||||
|
||||
### 3. Test Checklist:
|
||||
|
||||
- [ ] Dropdowns appear immediately when clicked
|
||||
- [ ] Red borders and debug headers are visible
|
||||
- [ ] Dropdowns appear above all other elements
|
||||
- [ ] Clicking outside closes dropdowns
|
||||
- [ ] Dropdowns work properly in table contexts
|
||||
- [ ] API calls update status correctly
|
||||
- [ ] Loading states show during updates
|
||||
- [ ] Error handling reverts status on failure
|
||||
|
||||
## 📁 Files Created/Modified
|
||||
|
||||
### New Components:
|
||||
|
||||
- `src/components/TaskStatusDropdownSimple.js` ✅
|
||||
- `src/components/ProjectStatusDropdownSimple.js` ✅
|
||||
- `src/app/test-dropdowns/page.js` ✅
|
||||
|
||||
### Updated Components:
|
||||
|
||||
- `src/components/ProjectTasksSection.js` ✅
|
||||
- `src/app/tasks/page.js` ✅
|
||||
- `src/components/ProjectTasksDashboard.js` ✅
|
||||
- `src/app/page.js` ✅
|
||||
|
||||
### Test Files:
|
||||
|
||||
- `test-dropdown-comprehensive.html` ✅
|
||||
- `test-dropdown.html` ✅
|
||||
|
||||
### Documentation:
|
||||
|
||||
- `DROPDOWN_IMPLEMENTATION_SUMMARY.md` ✅
|
||||
- `DROPDOWN_COMPLETION_STATUS.md` ✅ (this file)
|
||||
|
||||
## 🚀 Next Steps (Production Polish)
|
||||
|
||||
### 1. Remove Debug Features:
|
||||
|
||||
```javascript
|
||||
// Remove these debug elements:
|
||||
- Red borders (border-2 border-red-500)
|
||||
- Yellow debug headers
|
||||
- Console.log statements
|
||||
- Semi-transparent backdrop styling
|
||||
```
|
||||
|
||||
### 2. Final Styling:
|
||||
|
||||
```javascript
|
||||
// Replace debug styles with:
|
||||
border border-gray-200 // Subtle borders
|
||||
shadow-lg // Professional shadows
|
||||
Clean backdrop (transparent)
|
||||
```
|
||||
|
||||
### 3. Performance Optimization:
|
||||
|
||||
- Consider portal-based positioning for complex table layouts
|
||||
- Add keyboard navigation (Enter/Escape keys)
|
||||
- Implement click-outside using refs instead of global listeners
|
||||
|
||||
### 4. Code Cleanup:
|
||||
|
||||
- Remove original TaskStatusDropdown.js and ProjectStatusDropdown.js
|
||||
- Rename Simple components to drop "Simple" suffix
|
||||
- Update import statements across application
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
1. **Redundant UI Eliminated**: ✅ Single component replaces badge + dropdown pairs
|
||||
2. **Z-Index Issues Resolved**: ✅ Dropdowns appear above all elements
|
||||
3. **Table Compatibility**: ✅ Works properly in table/overflow contexts
|
||||
4. **API Integration**: ✅ Status updates via PATCH/PUT requests
|
||||
5. **Error Handling**: ✅ Reverts status on API failures
|
||||
6. **Loading States**: ✅ Shows "Updating..." during API calls
|
||||
7. **Consistent Styling**: ✅ Unified design patterns across components
|
||||
|
||||
## 🎉 Project Status: READY FOR TESTING
|
||||
|
||||
The dropdown consolidation is complete and ready for user testing. All components have been updated to use the simplified, working versions with debug features enabled for validation.
|
||||
@@ -1,142 +0,0 @@
|
||||
# Dropdown Consolidation - Implementation Summary
|
||||
|
||||
## Problem Identified
|
||||
|
||||
The project management interface had redundant status displays where both a status badge and a dropdown showing the same status information were displayed together. Additionally, there was a z-index issue where dropdowns appeared behind other elements.
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### 1. Created Unified Dropdown Components
|
||||
|
||||
#### TaskStatusDropdown Components:
|
||||
|
||||
- **TaskStatusDropdown.js** - Original enhanced component with portal positioning (currently has complexity issues)
|
||||
- **TaskStatusDropdownSimple.js** - ✅ Simplified working version for testing
|
||||
|
||||
#### ProjectStatusDropdown Components:
|
||||
|
||||
- **ProjectStatusDropdown.js** - Original enhanced component with portal positioning (currently has complexity issues)
|
||||
- **ProjectStatusDropdownSimple.js** - ✅ Simplified working version for testing
|
||||
|
||||
### 2. Key Features of Unified Components
|
||||
|
||||
#### Interactive Status Display:
|
||||
|
||||
- Single component serves as both status badge and dropdown
|
||||
- Click to expand dropdown with status options
|
||||
- Visual feedback (arrow rotation, hover effects)
|
||||
- Loading states during API calls
|
||||
|
||||
#### Debugging Features (Current Implementation):
|
||||
|
||||
- Console logging for click events
|
||||
- Visible red border around dropdown for testing
|
||||
- Yellow debug header showing dropdown is visible
|
||||
- Semi-transparent backdrop for easy identification
|
||||
|
||||
#### API Integration:
|
||||
|
||||
- TaskStatusDropdown: PATCH `/api/project-tasks/{id}`
|
||||
- ProjectStatusDropdown: PUT `/api/projects/{id}`
|
||||
- Callback support for parent component refresh
|
||||
- Error handling with status reversion
|
||||
|
||||
### 3. Updated Components
|
||||
|
||||
#### Currently Using Simplified Version:
|
||||
|
||||
- ✅ **ProjectTasksSection.js** - Task table uses TaskStatusDropdownSimple
|
||||
- ✅ **Test page created** - `/test-dropdowns` for isolated testing
|
||||
|
||||
#### Still Using Original (Need to Update):
|
||||
|
||||
- **ProjectTasksPage** (`/tasks`) - Uses TaskStatusDropdown
|
||||
- **ProjectTasksDashboard** - Uses TaskStatusDropdown
|
||||
- **Main Dashboard** (`/`) - Uses TaskStatusDropdown (read-only mode)
|
||||
- **Project Detail Pages** - Uses ProjectStatusDropdown
|
||||
|
||||
### 4. Configuration
|
||||
|
||||
#### Task Status Options:
|
||||
|
||||
- `pending` → Warning variant (yellow)
|
||||
- `in_progress` → Primary variant (blue)
|
||||
- `completed` → Success variant (green)
|
||||
- `cancelled` → Danger variant (red)
|
||||
|
||||
#### Project Status Options:
|
||||
|
||||
- `registered` → Secondary variant (gray)
|
||||
- `in_progress_design` → Primary variant (blue)
|
||||
- `in_progress_construction` → Primary variant (blue)
|
||||
- `fulfilled` → Success variant (green)
|
||||
|
||||
### 5. Z-Index Solution
|
||||
|
||||
- Dropdown: `z-[9999]` (maximum visibility)
|
||||
- Backdrop: `z-[9998]` (behind dropdown)
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ Working:
|
||||
|
||||
- Simplified dropdown components compile without errors
|
||||
- Basic dropdown structure and styling
|
||||
- Debug features for testing
|
||||
- Test page available at `/test-dropdowns`
|
||||
|
||||
### 🚧 In Progress:
|
||||
|
||||
- Testing dropdown visibility in browser
|
||||
- Development server startup (terminal access issues)
|
||||
|
||||
### 📋 Next Steps:
|
||||
|
||||
1. **Test Simplified Components**
|
||||
|
||||
- Verify dropdowns appear correctly
|
||||
- Test click interactions
|
||||
- Confirm API calls work
|
||||
|
||||
2. **Replace Original Components**
|
||||
|
||||
- Update remaining pages to use simplified versions
|
||||
- Remove complex portal/positioning code if simple version works
|
||||
|
||||
3. **Production Polish**
|
||||
|
||||
- Remove debug features (red borders, console logs)
|
||||
- Fine-tune styling and positioning
|
||||
- Add portal-based positioning if needed for table overflow
|
||||
|
||||
4. **Code Cleanup**
|
||||
- Remove unused original components
|
||||
- Clean up imports across all files
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
1. **Access Test Page**: Navigate to `/test-dropdowns`
|
||||
2. **Check Console**: Open browser dev tools (F12) → Console tab
|
||||
3. **Test Interactions**: Click dropdowns to see debug messages
|
||||
4. **Verify Visibility**: Look for red-bordered dropdowns with yellow debug headers
|
||||
|
||||
## Files Modified
|
||||
|
||||
### New Components:
|
||||
|
||||
- `src/components/TaskStatusDropdownSimple.js`
|
||||
- `src/components/ProjectStatusDropdownSimple.js`
|
||||
- `src/app/test-dropdowns/page.js`
|
||||
|
||||
### Updated Components:
|
||||
|
||||
- `src/components/ProjectTasksSection.js` (using simple version)
|
||||
- `src/components/TaskStatusDropdown.js` (enhanced but problematic)
|
||||
- `src/components/ProjectStatusDropdown.js` (enhanced but problematic)
|
||||
|
||||
### Test Files:
|
||||
|
||||
- `test-dropdown.html` (standalone HTML test)
|
||||
- `start-dev.bat` (development server script)
|
||||
|
||||
The consolidation successfully eliminates duplicate status displays and provides a unified interface for status management across the application.
|
||||
42
Dockerfile
42
Dockerfile
@@ -1,20 +1,54 @@
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install git and cron
|
||||
RUN apt-get update && apt-get install -y git cron && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if any)
|
||||
# If building from a git repository, clone it
|
||||
# This will be used when the build context doesn't include source files
|
||||
ARG GIT_REPO_URL
|
||||
ARG GIT_BRANCH=main
|
||||
ARG GIT_COMMIT
|
||||
|
||||
# If GIT_REPO_URL is provided, clone the repo; otherwise copy local files
|
||||
RUN if [ -n "$GIT_REPO_URL" ]; then \
|
||||
git clone --branch ${GIT_BRANCH} ${GIT_REPO_URL} . && \
|
||||
if [ -n "$GIT_COMMIT" ]; then git checkout ${GIT_COMMIT}; fi; \
|
||||
fi
|
||||
|
||||
# Copy package.json and package-lock.json (if not cloned from git)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the app
|
||||
# Copy the rest of the app (if not cloned from git)
|
||||
RUN if [ -z "$GIT_REPO_URL" ]; then echo "Copying local files..."; fi
|
||||
COPY . .
|
||||
|
||||
# Set Node options for build to prevent memory issues (adjusted for 3.8GB VPS RAM)
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Build the application for production
|
||||
RUN npm run build
|
||||
|
||||
# Copy the entrypoint script
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Make scripts executable
|
||||
RUN chmod +x backup-db.mjs send-due-date-reminders.mjs
|
||||
|
||||
# Expose the default Next.js port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the dev server
|
||||
CMD ["npm", "run", "dev"]
|
||||
# Use the entrypoint script
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
31
Dockerfile.dev
Normal file
31
Dockerfile.dev
Normal file
@@ -0,0 +1,31 @@
|
||||
# Use Node.js 22.11.0 as the base image
|
||||
FROM node:22.11.0
|
||||
|
||||
# Set timezone to Europe/Warsaw (Polish timezone)
|
||||
ENV TZ=Europe/Warsaw
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# Install git and cron for development
|
||||
RUN apt-get update && apt-get install -y git cron && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (if any)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Copy the development entrypoint script
|
||||
COPY docker-entrypoint-dev.sh /docker-entrypoint-dev.sh
|
||||
RUN chmod +x /docker-entrypoint-dev.sh
|
||||
|
||||
# Expose the default Next.js port
|
||||
EXPOSE 3000
|
||||
|
||||
# Use the development entrypoint script
|
||||
ENTRYPOINT ["/docker-entrypoint-dev.sh"]
|
||||
@@ -1,176 +0,0 @@
|
||||
# Edge Runtime Compatibility Fix - Final Solution
|
||||
|
||||
## Problem Resolved
|
||||
|
||||
The audit logging system was causing "Edge runtime does not support Node.js 'fs' module" errors because the `better-sqlite3` database module was being loaded in Edge Runtime contexts through static imports.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The middleware imports `auth.js` → which imported `auditLog.js` → which had a static import of `db.js` → which imports `better-sqlite3`. This caused the entire SQLite module to be loaded even in Edge Runtime where it's not supported.
|
||||
|
||||
## Final Solution
|
||||
|
||||
### 1. Created Safe Audit Logging Module
|
||||
|
||||
**File: `src/lib/auditLogSafe.js`**
|
||||
|
||||
This module provides:
|
||||
|
||||
- ✅ **No static database imports** - completely safe for Edge Runtime
|
||||
- ✅ **Runtime detection** - automatically detects Edge vs Node.js
|
||||
- ✅ **Graceful fallbacks** - console logging in Edge, database in Node.js
|
||||
- ✅ **Constants always available** - `AUDIT_ACTIONS` and `RESOURCE_TYPES`
|
||||
- ✅ **Async/await support** - works with modern API patterns
|
||||
|
||||
```javascript
|
||||
// Safe import that never causes Edge Runtime errors
|
||||
import {
|
||||
logAuditEventSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "./auditLogSafe.js";
|
||||
|
||||
// Works in any runtime
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.LOGIN,
|
||||
userId: "user123",
|
||||
resourceType: RESOURCE_TYPES.SESSION,
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Updated All Imports
|
||||
|
||||
**Files Updated:**
|
||||
|
||||
- `src/lib/auth.js` - Authentication logging
|
||||
- `src/app/api/projects/route.js` - Project operations
|
||||
- `src/app/api/projects/[id]/route.js` - Individual project operations
|
||||
- `src/app/api/notes/route.js` - Note operations
|
||||
|
||||
**Before:**
|
||||
|
||||
```javascript
|
||||
import { logApiAction, AUDIT_ACTIONS } from "@/lib/auditLog.js"; // ❌ Causes Edge Runtime errors
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```javascript
|
||||
import { logApiActionSafe, AUDIT_ACTIONS } from "@/lib/auditLogSafe.js"; // ✅ Edge Runtime safe
|
||||
```
|
||||
|
||||
### 3. Runtime Behavior
|
||||
|
||||
#### Edge Runtime
|
||||
|
||||
- **Detection**: Automatic via `typeof EdgeRuntime !== 'undefined'`
|
||||
- **Logging**: Console output only
|
||||
- **Performance**: Zero database overhead
|
||||
- **Errors**: None - completely safe
|
||||
|
||||
#### Node.js Runtime
|
||||
|
||||
- **Detection**: Automatic fallback when Edge Runtime not detected
|
||||
- **Logging**: Full database functionality via dynamic import
|
||||
- **Performance**: Full audit trail with database persistence
|
||||
- **Errors**: Graceful handling with console fallback
|
||||
|
||||
### 4. Migration Pattern
|
||||
|
||||
The safe module uses a smart delegation pattern:
|
||||
|
||||
```javascript
|
||||
// In Edge Runtime: Console logging only
|
||||
console.log(`[Audit] ${action} by user ${userId}`);
|
||||
|
||||
// In Node.js Runtime: Try database, fallback to console
|
||||
try {
|
||||
const auditModule = await import("./auditLog.js");
|
||||
auditModule.logAuditEvent({ ...params });
|
||||
} catch (dbError) {
|
||||
console.log("[Audit] Database logging failed, using console fallback");
|
||||
}
|
||||
```
|
||||
|
||||
## Files Structure
|
||||
|
||||
```
|
||||
src/lib/
|
||||
├── auditLog.js # Original - Node.js only (database operations)
|
||||
├── auditLogSafe.js # New - Universal (Edge + Node.js compatible)
|
||||
├── auditLogEdge.js # Alternative - Edge-specific with API calls
|
||||
└── auth.js # Updated to use safe imports
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the compatibility test:
|
||||
|
||||
```bash
|
||||
node test-safe-audit-logging.mjs
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
|
||||
```
|
||||
✅ Safe module imported successfully
|
||||
✅ Edge Runtime logging successful (console only)
|
||||
✅ Node.js Runtime logging successful (database + console)
|
||||
✅ Constants accessible
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
✅ **No more Edge Runtime errors**
|
||||
✅ **Middleware works without database dependencies**
|
||||
✅ **Authentication logging works in all contexts**
|
||||
✅ **API routes maintain full audit functionality**
|
||||
✅ **Constants available everywhere**
|
||||
✅ **Graceful degradation in Edge Runtime**
|
||||
✅ **Full functionality in Node.js Runtime**
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Edge Runtime**: Minimal - only console logging
|
||||
- **Node.js Runtime**: Same as before - full database operations
|
||||
- **Import cost**: Near zero - no static database imports
|
||||
- **Memory usage**: Significantly reduced in Edge Runtime
|
||||
|
||||
## Migration Guide
|
||||
|
||||
To update existing code:
|
||||
|
||||
1. **Replace imports:**
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
import { logApiAction } from "@/lib/auditLog.js";
|
||||
|
||||
// New
|
||||
import { logApiActionSafe } from "@/lib/auditLogSafe.js";
|
||||
```
|
||||
|
||||
2. **Update function calls:**
|
||||
|
||||
```javascript
|
||||
// Old
|
||||
logApiAction(req, action, type, id, session, details);
|
||||
|
||||
// New
|
||||
await logApiActionSafe(req, action, type, id, session, details);
|
||||
```
|
||||
|
||||
3. **Add runtime exports** (for API routes):
|
||||
```javascript
|
||||
export const runtime = "nodejs"; // For database-heavy routes
|
||||
```
|
||||
|
||||
## Best Practices Applied
|
||||
|
||||
1. **Separation of Concerns**: Safe module for universal use, full module for Node.js
|
||||
2. **Dynamic Imports**: Database modules loaded only when needed
|
||||
3. **Runtime Detection**: Automatic environment detection
|
||||
4. **Graceful Degradation**: Meaningful fallbacks in constrained environments
|
||||
5. **Error Isolation**: Audit failures don't break main application flow
|
||||
|
||||
The application now handles both Edge and Node.js runtimes seamlessly with zero Edge Runtime errors! 🎉
|
||||
@@ -1,161 +0,0 @@
|
||||
# Final Edge Runtime Fix - Audit Logging System
|
||||
|
||||
## ✅ **Issue Resolved**
|
||||
|
||||
The Edge Runtime error has been completely fixed! The audit logging system now works seamlessly across all Next.js runtime environments.
|
||||
|
||||
## 🔧 **Final Implementation**
|
||||
|
||||
### **Problem Summary**
|
||||
|
||||
- Edge Runtime was trying to load `better-sqlite3` (Node.js fs module)
|
||||
- Static imports in middleware caused the entire dependency chain to load
|
||||
- `middleware.js` → `auth.js` → `auditLog.js` → `db.js` → `better-sqlite3`
|
||||
|
||||
### **Solution Implemented**
|
||||
|
||||
#### 1. **Made All Functions Async**
|
||||
|
||||
```javascript
|
||||
// Before: Synchronous with require()
|
||||
export function logAuditEvent() {
|
||||
const { default: db } = require("./db.js");
|
||||
}
|
||||
|
||||
// After: Async with dynamic import
|
||||
export async function logAuditEvent() {
|
||||
const { default: db } = await import("./db.js");
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. **Runtime Detection & Graceful Fallbacks**
|
||||
|
||||
```javascript
|
||||
export async function logAuditEvent(params) {
|
||||
try {
|
||||
// Edge Runtime detection
|
||||
if (
|
||||
typeof EdgeRuntime !== "undefined" ||
|
||||
process.env.NEXT_RUNTIME === "edge"
|
||||
) {
|
||||
console.log(`[Audit Log - Edge Runtime] ${action} by user ${userId}`);
|
||||
return; // Graceful exit
|
||||
}
|
||||
|
||||
// Node.js Runtime: Full database functionality
|
||||
const { default: db } = await import("./db.js");
|
||||
// ... database operations
|
||||
} catch (error) {
|
||||
console.error("Failed to log audit event:", error);
|
||||
// Non-breaking error handling
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. **Safe Wrapper Module (`auditLogSafe.js`)**
|
||||
|
||||
```javascript
|
||||
export async function logAuditEventSafe(params) {
|
||||
console.log(`[Audit] ${action} by user ${userId}`); // Always log to console
|
||||
|
||||
if (typeof EdgeRuntime !== "undefined") {
|
||||
return; // Edge Runtime: Console only
|
||||
}
|
||||
|
||||
try {
|
||||
const auditModule = await import("./auditLog.js");
|
||||
await auditModule.logAuditEvent(params); // Node.js: Database + console
|
||||
} catch (error) {
|
||||
console.log("[Audit] Database logging failed, using console fallback");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎯 **Runtime Behavior**
|
||||
|
||||
| Runtime | Behavior | Database | Console | Errors |
|
||||
| ----------- | ------------------------ | -------- | ------- | ---------------------- |
|
||||
| **Edge** | Console logging only | ❌ | ✅ | ❌ Zero errors |
|
||||
| **Node.js** | Full audit functionality | ✅ | ✅ | ❌ Full error handling |
|
||||
|
||||
## ✅ **Test Results**
|
||||
|
||||
```bash
|
||||
$ node test-safe-audit-logging.mjs
|
||||
|
||||
Testing Safe Audit Logging...
|
||||
|
||||
1. Testing safe module import...
|
||||
✅ Safe module imported successfully
|
||||
Available actions: 27
|
||||
Available resource types: 8
|
||||
|
||||
2. Testing in simulated Edge Runtime...
|
||||
[Audit] project_view by user anonymous on project:test-123
|
||||
[Audit] Edge Runtime detected - console logging only
|
||||
✅ Edge Runtime logging successful (console only)
|
||||
|
||||
3. Testing in simulated Node.js Runtime...
|
||||
[Audit] project_create by user anonymous on project:test-456
|
||||
Audit log: project_create by user anonymous on project:test-456
|
||||
✅ Node.js Runtime logging successful (database + console)
|
||||
|
||||
4. Testing constants accessibility...
|
||||
✅ Constants accessible:
|
||||
LOGIN action: login
|
||||
PROJECT resource: project
|
||||
NOTE_CREATE action: note_create
|
||||
|
||||
✅ Safe Audit Logging test completed!
|
||||
|
||||
Key features verified:
|
||||
- ✅ No static database imports
|
||||
- ✅ Edge Runtime compatibility
|
||||
- ✅ Graceful fallbacks
|
||||
- ✅ Constants always available
|
||||
- ✅ Async/await support
|
||||
|
||||
The middleware should now work without Edge Runtime errors!
|
||||
```
|
||||
|
||||
## 📁 **Files Updated**
|
||||
|
||||
### **Core Audit System**
|
||||
|
||||
- ✅ `src/lib/auditLog.js` - Made all functions async, removed static imports
|
||||
- ✅ `src/lib/auditLogSafe.js` - New Edge-compatible wrapper module
|
||||
|
||||
### **Authentication**
|
||||
|
||||
- ✅ `src/lib/auth.js` - Updated to use safe audit logging
|
||||
|
||||
### **API Routes**
|
||||
|
||||
- ✅ `src/app/api/audit-logs/route.js` - Updated for async functions
|
||||
- ✅ `src/app/api/audit-logs/stats/route.js` - Updated for async functions
|
||||
- ✅ `src/app/api/audit-logs/log/route.js` - Updated for async functions
|
||||
- ✅ `src/app/api/projects/route.js` - Using safe audit logging
|
||||
- ✅ `src/app/api/projects/[id]/route.js` - Using safe audit logging
|
||||
- ✅ `src/app/api/notes/route.js` - Using safe audit logging
|
||||
|
||||
## 🚀 **Benefits Achieved**
|
||||
|
||||
1. **✅ Zero Edge Runtime Errors** - No more fs module conflicts
|
||||
2. **✅ Universal Compatibility** - Works in any Next.js runtime environment
|
||||
3. **✅ No Functionality Loss** - Full audit trail in production (Node.js runtime)
|
||||
4. **✅ Graceful Degradation** - Meaningful console logging in Edge Runtime
|
||||
5. **✅ Performance Optimized** - No unnecessary database loads in Edge Runtime
|
||||
6. **✅ Developer Friendly** - Clear logging shows what's happening in each runtime
|
||||
|
||||
## 🎉 **Final Status**
|
||||
|
||||
**The audit logging system is now production-ready and Edge Runtime compatible!**
|
||||
|
||||
- **Middleware**: ✅ Works without errors
|
||||
- **Authentication**: ✅ Logs login/logout events
|
||||
- **API Routes**: ✅ Full audit trail for CRUD operations
|
||||
- **Admin Interface**: ✅ View audit logs at `/admin/audit-logs`
|
||||
- **Edge Runtime**: ✅ Zero errors, console fallbacks
|
||||
- **Node.js Runtime**: ✅ Full database functionality
|
||||
|
||||
Your application should now run perfectly without any Edge Runtime errors while maintaining comprehensive audit logging! 🎊
|
||||
@@ -1,137 +0,0 @@
|
||||
# Polish Geospatial Layers Integration - COMPLETE SUCCESS! 🎉
|
||||
|
||||
## ✅ Mission Accomplished
|
||||
|
||||
All Polish geospatial layers including Google Maps have been successfully integrated into the main project's mapping system. The integration maintains proper transparency handling and provides a comprehensive mapping solution.
|
||||
|
||||
## 🚀 What Was Implemented
|
||||
|
||||
### 1. Enhanced Layer Configuration (`mapLayers.js`)
|
||||
**Before**: Only basic OpenStreetMap + simple Polish orthophoto
|
||||
**After**: 8 base layers + 6 overlay layers with full transparency support
|
||||
|
||||
### 2. Updated Main Map Components
|
||||
- **`LeafletMap.js`** - Main project map component ✅
|
||||
- **`EnhancedLeafletMap.js`** - Enhanced map variant ✅
|
||||
- Added `WMSTileLayer` import and proper overlay handling
|
||||
|
||||
### 3. Comprehensive Layer Selection
|
||||
|
||||
#### Base Layers (8 total)
|
||||
1. **OpenStreetMap** (default)
|
||||
2. **🇵🇱 Polish Orthophoto (Standard)** - WMTS format
|
||||
3. **🇵🇱 Polish Orthophoto (High Resolution)** - WMTS format
|
||||
4. **🌍 Google Satellite** - Global satellite imagery
|
||||
5. **🌍 Google Hybrid** - Satellite + roads
|
||||
6. **🌍 Google Roads** - Road map
|
||||
7. **Satellite (Esri)** - Alternative satellite
|
||||
8. **Topographic** - CartoDB topographic
|
||||
|
||||
#### Overlay Layers (6 total with transparency)
|
||||
1. **📋 Polish Cadastral Data** (WMS, 80% opacity)
|
||||
2. **🏗️ Polish Spatial Planning** (WMS, 70% opacity)
|
||||
3. **🛣️ LP-Portal Roads** (WMS, 90% opacity)
|
||||
4. **🏷️ LP-Portal Street Names** (WMS, 100% opacity)
|
||||
5. **📐 LP-Portal Parcels** (WMS, 60% opacity)
|
||||
6. **📍 LP-Portal Survey Markers** (WMS, 80% opacity)
|
||||
|
||||
## 🎯 Key Features Implemented
|
||||
|
||||
### Layer Control Interface
|
||||
- **📚 Layer Control Button** in top-right corner
|
||||
- **Radio buttons** for base layers (mutually exclusive)
|
||||
- **Checkboxes** for overlays (can combine multiple)
|
||||
- **Emoji icons** for easy layer identification
|
||||
|
||||
### Transparency System
|
||||
- **Base layers**: Fully opaque backgrounds
|
||||
- **Overlay layers**: Each with optimized transparency:
|
||||
- Property boundaries: Semi-transparent for visibility
|
||||
- Planning zones: Semi-transparent for context
|
||||
- Roads: Mostly opaque for navigation
|
||||
- Text labels: Fully opaque for readability
|
||||
- Survey data: Semi-transparent for reference
|
||||
|
||||
### Technical Excellence
|
||||
- **WMTS Integration**: Proper KVP format for Polish orthophoto
|
||||
- **WMS Integration**: Transparent PNG overlays with correct parameters
|
||||
- **Performance**: Efficient tile loading and layer switching
|
||||
- **Compatibility**: Works with existing project structure
|
||||
- **SSR Safe**: Proper dynamic imports for Next.js
|
||||
|
||||
## 🌍 Geographic Coverage
|
||||
|
||||
### Poland-Specific Layers
|
||||
- **Polish Orthophoto**: Complete national coverage at high resolution
|
||||
- **Cadastral Data**: Official property boundaries nationwide
|
||||
- **Spatial Planning**: Zoning data where available
|
||||
- **LP-Portal**: Municipal data for specific regions
|
||||
|
||||
### Global Layers
|
||||
- **Google Services**: Worldwide satellite and road data
|
||||
- **Esri Satellite**: Global high-resolution imagery
|
||||
- **OpenStreetMap**: Community-driven global mapping
|
||||
|
||||
## 📱 Where It's Available
|
||||
|
||||
### Main Project Maps
|
||||
- **`/projects/map`** - Projects overview map ✅
|
||||
- **Individual project cards** - Project location maps ✅
|
||||
- **All existing map components** - Enhanced with new layers ✅
|
||||
|
||||
### Demo/Test Pages (Still Available)
|
||||
- **`/comprehensive-polish-map`** - Full-featured demo
|
||||
- **`/test-polish-map`** - Layer comparison
|
||||
- **`/debug-polish-orthophoto`** - Technical testing
|
||||
|
||||
## 🔧 Code Changes Summary
|
||||
|
||||
### Layer Configuration (`mapLayers.js`)
|
||||
```javascript
|
||||
// Added 6 new base layers including Polish orthophoto + Google
|
||||
// Added 6 overlay layers with WMS configuration
|
||||
// Proper transparency and opacity settings
|
||||
```
|
||||
|
||||
### Map Components (`LeafletMap.js`, `EnhancedLeafletMap.js`)
|
||||
```javascript
|
||||
// Added WMSTileLayer import
|
||||
// Added Overlay component support
|
||||
// Layer control with both BaseLayer and Overlay
|
||||
// Transparency parameter handling
|
||||
```
|
||||
|
||||
## 🎯 User Experience
|
||||
|
||||
### Easy Layer Selection
|
||||
1. Click **📚** layer control button
|
||||
2. Select base layer (aerial photos, satellite, roads, etc.)
|
||||
3. Check/uncheck overlays (property boundaries, planning, etc.)
|
||||
4. Layers update instantly
|
||||
|
||||
### Visual Clarity
|
||||
- **Emojis** make layer types instantly recognizable
|
||||
- **Proper transparency** prevents overlays from obscuring base maps
|
||||
- **Performance** optimized for smooth switching
|
||||
|
||||
## 🚀 Ready for Production
|
||||
|
||||
✅ **Integration Complete**: All layers working in main project maps
|
||||
✅ **Transparency Handled**: Overlays properly configured with opacity
|
||||
✅ **Performance Optimized**: Efficient loading and switching
|
||||
✅ **User-Friendly**: Clear interface with emoji identifiers
|
||||
✅ **Tested**: Development server running successfully
|
||||
✅ **Documented**: Comprehensive guides available
|
||||
|
||||
## 🎉 Final Result
|
||||
|
||||
The project now has **enterprise-grade Polish geospatial capabilities** integrated directly into the main mapping system. Users can access:
|
||||
|
||||
- **High-resolution Polish orthophoto** from official government sources
|
||||
- **Official cadastral data** for property boundaries
|
||||
- **Spatial planning information** for zoning
|
||||
- **Municipal data** from LP-Portal
|
||||
- **Global satellite imagery** from Google and Esri
|
||||
- **Full transparency control** for overlay combinations
|
||||
|
||||
**Mission: ACCOMPLISHED!** 🚀🗺️🇵🇱
|
||||
@@ -1,116 +0,0 @@
|
||||
# Polish Geospatial Layers Integration - Project Maps Complete! 🎉
|
||||
|
||||
## ✅ Successfully Integrated Into Main Project Maps
|
||||
|
||||
All Polish geospatial layers and Google layers have been successfully integrated into the main project's mapping system.
|
||||
|
||||
## 🗺️ Available Layers in Project Maps
|
||||
|
||||
### Base Layers (Mutually Exclusive)
|
||||
1. **OpenStreetMap** - Default layer
|
||||
2. **🇵🇱 Polish Orthophoto (Standard)** - High-quality aerial imagery
|
||||
3. **🇵🇱 Polish Orthophoto (High Resolution)** - Ultra-high resolution aerial imagery
|
||||
4. **🌍 Google Satellite** - Google satellite imagery
|
||||
5. **🌍 Google Hybrid** - Google satellite with roads overlay
|
||||
6. **🌍 Google Roads** - Google road map
|
||||
7. **Satellite (Esri)** - Esri world imagery
|
||||
8. **Topographic** - CartoDB Voyager topographic map
|
||||
|
||||
### Overlay Layers (Can be Combined with Transparency)
|
||||
1. **📋 Polish Cadastral Data** - Property boundaries and parcel information (80% opacity)
|
||||
2. **🏗️ Polish Spatial Planning** - Zoning and urban planning data (70% opacity)
|
||||
3. **🛣️ LP-Portal Roads** - Detailed road network (90% opacity)
|
||||
4. **🏷️ LP-Portal Street Names** - Street names and descriptions (100% opacity)
|
||||
5. **📐 LP-Portal Parcels** - Municipal property parcels (60% opacity)
|
||||
6. **📍 LP-Portal Survey Markers** - Survey markers and reference points (80% opacity)
|
||||
|
||||
## 📁 Updated Files
|
||||
|
||||
### Core Map Components
|
||||
- **`src/components/ui/LeafletMap.js`** - Main project map component ✅
|
||||
- **`src/components/ui/EnhancedLeafletMap.js`** - Enhanced map component ✅
|
||||
- **`src/components/ui/mapLayers.js`** - Layer configuration ✅
|
||||
|
||||
### Map Usage in Project
|
||||
- **`src/app/projects/map/page.js`** - Projects map page (uses LeafletMap)
|
||||
- **`src/components/ui/ProjectMap.js`** - Individual project maps (uses LeafletMap)
|
||||
|
||||
## 🚀 How It Works
|
||||
|
||||
### Layer Control
|
||||
- **Layer Control Button** (📚) appears in top-right corner of maps
|
||||
- **Base Layers** - Radio buttons (only one can be selected)
|
||||
- **Overlay Layers** - Checkboxes (multiple can be selected)
|
||||
|
||||
### Transparency Handling
|
||||
- **Base layers** are fully opaque (no transparency)
|
||||
- **Overlay layers** have appropriate transparency levels:
|
||||
- Cadastral data: Semi-transparent for property boundaries
|
||||
- Planning data: Semi-transparent for zoning information
|
||||
- Roads: Mostly opaque for visibility
|
||||
- Street names: Fully opaque for text readability
|
||||
- Parcels: Semi-transparent for boundary visualization
|
||||
- Survey markers: Semi-transparent for reference points
|
||||
|
||||
### Automatic Integration
|
||||
All existing project maps now have access to:
|
||||
- Polish orthophoto layers
|
||||
- Google satellite/road layers
|
||||
- Polish government WMS overlays
|
||||
- LP-Portal municipal data overlays
|
||||
|
||||
## 🎯 Benefits
|
||||
|
||||
1. **Enhanced Mapping Capabilities**: Rich selection of base layers for different use cases
|
||||
2. **Polish-Specific Data**: Access to official Polish cadastral and planning data
|
||||
3. **Transparency Support**: Overlays work correctly with transparency
|
||||
4. **Maintained Performance**: Layers load efficiently and switch smoothly
|
||||
5. **User-Friendly**: Clear naming with emojis for easy identification
|
||||
|
||||
## 🌍 Geographic Coverage
|
||||
|
||||
- **Polish Orthophoto**: Complete coverage of Poland
|
||||
- **Polish Cadastral**: Official property boundaries across Poland
|
||||
- **Polish Planning**: Zoning data where available
|
||||
- **LP-Portal**: Municipal data (specific regions)
|
||||
- **Google Layers**: Global coverage
|
||||
- **Esri Satellite**: Global coverage
|
||||
|
||||
## 📱 Test Locations
|
||||
|
||||
Perfect locations to test all layers:
|
||||
- **Kraków**: [50.0647, 19.9450] - Historic center with detailed cadastral data
|
||||
- **Warszawa**: [52.2297, 21.0122] - Capital city with planning data
|
||||
- **Gdańsk**: [54.3520, 18.6466] - Port city with orthophoto coverage
|
||||
- **Wrocław**: [51.1079, 17.0385] - University city
|
||||
- **Poznań**: [52.4064, 16.9252] - Industrial center
|
||||
|
||||
## 🔧 Technical Implementation
|
||||
|
||||
### WMTS Integration
|
||||
- Polish orthophoto uses proper WMTS KVP format
|
||||
- EPSG:3857 coordinate system for Leaflet compatibility
|
||||
- Standard 256x256 tile size for optimal performance
|
||||
|
||||
### WMS Overlay Integration
|
||||
- Transparent PNG format for overlays
|
||||
- Proper parameter configuration for each service
|
||||
- Optimized opacity levels for each overlay type
|
||||
- Tiled requests for better performance
|
||||
|
||||
### React/Leaflet Architecture
|
||||
- Uses `react-leaflet` components: `TileLayer` and `WMSTileLayer`
|
||||
- Proper layer control with `BaseLayer` and `Overlay` components
|
||||
- Icon fixes for marker display
|
||||
- SSR-safe dynamic imports
|
||||
|
||||
## 🎉 Status: COMPLETE
|
||||
|
||||
✅ All Polish geospatial layers integrated
|
||||
✅ Google layers integrated
|
||||
✅ Transparency properly handled
|
||||
✅ Layer control working
|
||||
✅ Project maps updated
|
||||
✅ Documentation complete
|
||||
|
||||
The main project maps now have comprehensive Polish geospatial capabilities with proper transparency support! 🚀
|
||||
@@ -1,47 +0,0 @@
|
||||
# Merge Complete - auth2 to main
|
||||
|
||||
## Summary
|
||||
Successfully merged the `auth2` branch into the `main` branch on **2024-12-29**.
|
||||
|
||||
## What was merged
|
||||
The `auth2` branch contained extensive authentication and authorization features:
|
||||
|
||||
### Core Features Added:
|
||||
1. **Authentication System** - Complete NextAuth.js implementation with database sessions
|
||||
2. **Authorization & Access Control** - Role-based permissions (admin, user, guest)
|
||||
3. **User Management** - Admin interface for user creation, editing, and role management
|
||||
4. **Audit Logging** - Comprehensive logging of all user actions and system events
|
||||
5. **Edge Runtime Compatibility** - Fixed SSR and build issues for production deployment
|
||||
|
||||
### Technical Improvements:
|
||||
- **SSR Fixes** - Resolved all Server-Side Rendering issues with map components and auth pages
|
||||
- **Build Optimization** - Project now builds cleanly without errors
|
||||
- **UI/UX Preservation** - Maintained all original functionality, especially the projects map view
|
||||
- **Security Enhancements** - Added middleware for route protection and audit logging
|
||||
|
||||
## Files Modified/Added:
|
||||
- **98 files changed** with 9,544 additions and 658 deletions
|
||||
- Major additions include authentication pages, admin interfaces, API routes, and middleware
|
||||
- All test/debug pages moved to `debug-disabled/` folder to keep them out of production builds
|
||||
|
||||
## Verification:
|
||||
✅ Build completed successfully (`npm run build`)
|
||||
✅ Development server starts without errors (`npm run dev`)
|
||||
✅ All pages load correctly, including `/projects/map`
|
||||
✅ Original UI/UX functionality preserved
|
||||
✅ Authentication and authorization working as expected
|
||||
|
||||
## Post-Merge Status:
|
||||
- **Current branch**: `main`
|
||||
- **Remote status**: All changes pushed to origin/main
|
||||
- **Ready for production**: Yes, all SSR issues resolved
|
||||
- **Authentication**: Fully functional with admin panel at `/admin/users`
|
||||
|
||||
## Next Steps:
|
||||
1. Test the production deployment
|
||||
2. Create initial admin user using `node scripts/create-admin.js`
|
||||
3. Monitor audit logs for any issues
|
||||
4. Consider cleaning up old test files in future iterations
|
||||
|
||||
---
|
||||
*Merge completed by GitHub Copilot on 2024-12-29*
|
||||
@@ -1,90 +0,0 @@
|
||||
# Branch Merge Preparation Summary
|
||||
|
||||
## ✅ Completed Tasks
|
||||
|
||||
### 1. Build Issues Fixed
|
||||
- **SSR Issues**: Fixed server-side rendering issues with Leaflet map components
|
||||
- **useSearchParams**: Added Suspense boundaries to all pages using useSearchParams
|
||||
- **Dynamic Imports**: Implemented proper dynamic imports for map components
|
||||
- **Build Success**: Project now builds successfully without errors
|
||||
|
||||
### 2. Code Quality Improvements
|
||||
- **README Updated**: Comprehensive documentation reflecting current project state
|
||||
- **Project Structure**: Updated project structure documentation
|
||||
- **API Documentation**: Added complete API endpoint documentation
|
||||
- **Clean Build**: All pages compile and build correctly
|
||||
|
||||
### 3. Debug Pages Management
|
||||
- **Temporary Relocation**: Moved debug/test pages to `debug-disabled/` folder
|
||||
- **Build Optimization**: Removed non-production pages from build process
|
||||
- **Development Tools**: Preserved debug functionality for future development
|
||||
|
||||
### 4. Authentication & Authorization
|
||||
- **Auth Pages Fixed**: All authentication pages now build correctly
|
||||
- **Suspense Boundaries**: Proper error boundaries for auth components
|
||||
- **Session Management**: Maintained existing auth functionality
|
||||
|
||||
## 🔍 Current State
|
||||
|
||||
### Build Status
|
||||
- ✅ **npm run build**: Successful
|
||||
- ✅ **34 pages**: All pages compile
|
||||
- ✅ **Static Generation**: Working correctly
|
||||
- ⚠️ **ESLint Warning**: Parser serialization issue (non-blocking)
|
||||
|
||||
### Branch Status
|
||||
- **Branch**: `auth2`
|
||||
- **Status**: Ready for merge to main
|
||||
- **Commit**: `faeb1ca` - "Prepare branch for merge to main"
|
||||
- **Files Changed**: 13 files modified/moved
|
||||
|
||||
## 🚀 Next Steps for Merge
|
||||
|
||||
### 1. Pre-merge Checklist
|
||||
- [x] All build errors resolved
|
||||
- [x] Documentation updated
|
||||
- [x] Non-production code moved
|
||||
- [x] Changes committed
|
||||
- [ ] Final testing (recommended)
|
||||
- [ ] Merge to main branch
|
||||
|
||||
### 2. Post-merge Tasks
|
||||
- [ ] Re-enable debug pages if needed (move back from `debug-disabled/`)
|
||||
- [ ] Fix ESLint parser configuration
|
||||
- [ ] Add integration tests
|
||||
- [ ] Deploy to production
|
||||
|
||||
### 3. Optional Improvements
|
||||
- [ ] Fix ESLint configuration for better linting
|
||||
- [ ] Add more comprehensive error handling
|
||||
- [ ] Optimize bundle size
|
||||
- [ ] Add more unit tests
|
||||
|
||||
## 📝 Files Modified
|
||||
|
||||
### Core Changes
|
||||
- `README.md` - Updated comprehensive documentation
|
||||
- `src/app/auth/error/page.js` - Added Suspense boundary
|
||||
- `src/app/auth/signin/page.js` - Added Suspense boundary
|
||||
- `src/app/projects/[id]/page.js` - Fixed dynamic import
|
||||
- `src/app/projects/map/page.js` - Added Suspense boundary
|
||||
- `src/components/ui/ClientProjectMap.js` - New client component wrapper
|
||||
|
||||
### Debug Pages (Temporarily Moved)
|
||||
- `debug-disabled/debug-polish-orthophoto/` - Polish orthophoto debug
|
||||
- `debug-disabled/test-polish-orthophoto/` - Polish orthophoto test
|
||||
- `debug-disabled/test-polish-map/` - Polish map test
|
||||
- `debug-disabled/test-improved-wmts/` - WMTS test
|
||||
- `debug-disabled/comprehensive-polish-map/` - Comprehensive map test
|
||||
|
||||
## 🎯 Recommendation
|
||||
|
||||
**The branch is now ready for merge to main.** All critical build issues have been resolved, and the project builds successfully. The debug pages have been temporarily moved to prevent build issues while preserving their functionality for future development.
|
||||
|
||||
To proceed with the merge:
|
||||
1. Switch to main branch: `git checkout main`
|
||||
2. Merge auth2 branch: `git merge auth2`
|
||||
3. Push to origin: `git push origin main`
|
||||
4. Deploy if needed
|
||||
|
||||
The project is now in a stable state with comprehensive authentication, project management, and mapping functionality.
|
||||
@@ -1,139 +0,0 @@
|
||||
# Polish Geospatial Layers Integration Guide
|
||||
|
||||
## 🎯 All 4+ Polish Layers Successfully Implemented!
|
||||
|
||||
This document shows how to use the comprehensive Polish geospatial layers that have been converted from your OpenLayers implementation to work with Leaflet/React.
|
||||
|
||||
## 📦 Available Components
|
||||
|
||||
### Complete Map Components
|
||||
- `ComprehensivePolishMap.js` - Full-featured map with all layers
|
||||
- `AdvancedPolishOrthophotoMap.js` - Advanced map with overlays
|
||||
- `PolishOrthophotoMap.js` - Basic map with Polish orthophoto
|
||||
|
||||
### Individual Layer Components
|
||||
- `PolishGeoLayers.js` - Individual layer components for custom integration
|
||||
|
||||
## 🗺️ Implemented Layers
|
||||
|
||||
### Base Layers (WMTS)
|
||||
1. **Polish Orthophoto Standard Resolution** ✅
|
||||
- URL: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution`
|
||||
- Format: JPEG, Max Zoom: 19
|
||||
|
||||
2. **Polish Orthophoto High Resolution** ✅
|
||||
- URL: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution`
|
||||
- Format: JPEG, Max Zoom: 19
|
||||
|
||||
### Overlay Layers (WMS)
|
||||
|
||||
3. **Polish Cadastral Data (Działki)** ✅
|
||||
- Service: GUGiK Krajowa Integracja Ewidencji Gruntów
|
||||
- Layers: Property boundaries, parcels, buildings
|
||||
- Format: PNG (transparent)
|
||||
|
||||
4. **Polish Spatial Planning (MPZT)** ✅
|
||||
- Service: Geoportal Spatial Planning Integration
|
||||
- Layers: Zoning, planning boundaries, land use
|
||||
- Format: PNG (transparent)
|
||||
|
||||
### Additional LP-Portal Layers
|
||||
5. **LP-Portal Roads** ✅
|
||||
6. **LP-Portal Street Names** ✅
|
||||
7. **LP-Portal Property Parcels** ✅
|
||||
8. **LP-Portal Survey Markers** ✅
|
||||
|
||||
## 🚀 How to Use
|
||||
|
||||
### Option 1: Use Complete Component
|
||||
```jsx
|
||||
import ComprehensivePolishMap from '../components/ui/ComprehensivePolishMap';
|
||||
|
||||
export default function MyPage() {
|
||||
return (
|
||||
<div style={{ height: '500px' }}>
|
||||
<ComprehensivePolishMap
|
||||
center={[50.0647, 19.9450]} // Krakow
|
||||
zoom={14}
|
||||
markers={[]}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Use Individual Layers
|
||||
```jsx
|
||||
import { MapContainer, LayersControl } from 'react-leaflet';
|
||||
import {
|
||||
PolishOrthophotoStandard,
|
||||
PolishCadastralData,
|
||||
LPPortalRoads
|
||||
} from '../components/ui/PolishGeoLayers';
|
||||
|
||||
export default function CustomMap() {
|
||||
const { BaseLayer, Overlay } = LayersControl;
|
||||
|
||||
return (
|
||||
<MapContainer center={[50.0647, 19.9450]} zoom={14}>
|
||||
<LayersControl>
|
||||
<BaseLayer checked name="Polish Orthophoto">
|
||||
<PolishOrthophotoStandard />
|
||||
</BaseLayer>
|
||||
|
||||
<Overlay name="Property Boundaries">
|
||||
<PolishCadastralData />
|
||||
</Overlay>
|
||||
|
||||
<Overlay name="Roads">
|
||||
<LPPortalRoads />
|
||||
</Overlay>
|
||||
</LayersControl>
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 📍 Test Locations
|
||||
|
||||
Good locations to test the layers:
|
||||
- **Kraków**: [50.0647, 19.9450] - Historic center
|
||||
- **Warszawa**: [52.2297, 21.0122] - Capital city
|
||||
- **Gdańsk**: [54.3520, 18.6466] - Port city
|
||||
- **Wrocław**: [51.1079, 17.0385] - University city
|
||||
- **Poznań**: [52.4064, 16.9252] - Industrial center
|
||||
|
||||
## ⚙️ Technical Details
|
||||
|
||||
### WMTS Implementation
|
||||
- Uses proper KVP (Key-Value Pair) URL format
|
||||
- EPSG:3857 coordinate system for Leaflet compatibility
|
||||
- Standard tile size (256x256)
|
||||
|
||||
### WMS Implementation
|
||||
- Transparent PNG overlays
|
||||
- Proper parameter configuration
|
||||
- Tiled requests for better performance
|
||||
|
||||
### Performance Considerations
|
||||
- All layers use standard web projections
|
||||
- Optimized for React/Leaflet
|
||||
- Minimal additional dependencies (only proj4 for future enhancements)
|
||||
|
||||
## 🎉 Success!
|
||||
|
||||
All layers from your OpenLayers implementation are now working in your Leaflet-based React/Next.js project:
|
||||
|
||||
✅ Polish Orthophoto (Standard & High-Res)
|
||||
✅ Polish Cadastral Data (Property boundaries)
|
||||
✅ Polish Spatial Planning (Zoning data)
|
||||
✅ LP-Portal Municipal Data (Roads, names, parcels, surveys)
|
||||
|
||||
The implementation maintains the same functionality as your original OpenLayers code while being fully compatible with your existing React/Leaflet architecture.
|
||||
|
||||
## 📱 Test Pages Available
|
||||
|
||||
- `/comprehensive-polish-map` - Full featured map
|
||||
- `/test-polish-map` - Basic comparison
|
||||
- `/test-improved-wmts` - Technical testing
|
||||
795
ROADMAP.md
795
ROADMAP.md
@@ -1,314 +1,563 @@
|
||||
# App Development Roadmap
|
||||
# eProjektant Wastpol - Development Roadmap
|
||||
|
||||
## Current Application Assessment
|
||||
|
||||
This is a solid Next.js-based project management system for construction/engineering projects with the following existing features:
|
||||
|
||||
### ✅ Currently Implemented
|
||||
|
||||
- **Project Management**: CRUD operations for projects with detailed information
|
||||
- **Contract Management**: Contract creation, linking to projects, status tracking
|
||||
- **Task Management**: Template-based and custom tasks with status tracking
|
||||
- **Dashboard**: Statistics overview, recent projects, quick actions
|
||||
- **Map Integration**: Leaflet maps with multiple layer support (OpenStreetMap, Polish Geoportal)
|
||||
- **Database**: SQLite with better-sqlite3, well-structured schema
|
||||
- **UI/UX**: Modern Tailwind CSS interface with responsive design
|
||||
- **API Structure**: RESTful API endpoints for all entities
|
||||
- **Docker Support**: Containerized development and deployment
|
||||
- **Testing Setup**: Jest, Playwright, Testing Library configured
|
||||
**Last Updated**: January 16, 2026
|
||||
**Version**: 0.1.1
|
||||
**Status**: Production-Ready Foundation
|
||||
|
||||
---
|
||||
|
||||
## Critical Missing Features for App
|
||||
## 📊 Current Application Status
|
||||
|
||||
### 🔐 **1. Authentication & Authorization (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: No authentication system
|
||||
**Required**:
|
||||
|
||||
- User login/logout system
|
||||
- Role-based access control (Admin, Project Manager, User, Read-only)
|
||||
- Session management
|
||||
- Password reset functionality
|
||||
- User management interface
|
||||
- API route protection
|
||||
|
||||
**Implementation Options**:
|
||||
|
||||
- NextAuth.js with database sessions
|
||||
- Auth0 integration
|
||||
- Custom JWT implementation
|
||||
|
||||
### 🔒 **2. Security & Data Protection (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: No security measures
|
||||
**Required**:
|
||||
|
||||
- Input validation and sanitization
|
||||
- SQL injection protection (prepared statements are good start)
|
||||
- XSS protection
|
||||
- CSRF protection
|
||||
- Rate limiting
|
||||
- Environment variable security
|
||||
- Data encryption for sensitive fields
|
||||
- Audit logging
|
||||
|
||||
### 📊 **3. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic dashboard statistics
|
||||
**Required**:
|
||||
|
||||
- Project timeline reports
|
||||
- Budget tracking and financial reports
|
||||
- Task completion analytics
|
||||
- Project performance metrics
|
||||
- Export to PDF/Excel
|
||||
- Custom report builder
|
||||
- Charts and graphs (Chart.js, D3.js)
|
||||
|
||||
### 💾 **4. Backup & Data Management (HIGH PRIORITY)**
|
||||
|
||||
**Current State**: Single SQLite file
|
||||
**Required**:
|
||||
|
||||
- Automated database backups
|
||||
- Data export/import functionality
|
||||
- Database migration system
|
||||
- Data archiving for old projects
|
||||
- Recovery procedures
|
||||
|
||||
### 📱 **5. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic responsive design
|
||||
**Required**:
|
||||
|
||||
- Progressive Web App capabilities
|
||||
- Offline functionality
|
||||
- Mobile-optimized interface
|
||||
- Push notifications
|
||||
- App manifest and service workers
|
||||
|
||||
### 🔗 **6. API & Integration (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Internal REST API only
|
||||
**Required**:
|
||||
|
||||
- External API integrations (accounting software, CRM)
|
||||
- Webhook support
|
||||
- API documentation (Swagger/OpenAPI)
|
||||
- API versioning
|
||||
- Third-party service integrations
|
||||
|
||||
### 📧 **7. Communication & Notifications (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: No notification system
|
||||
**Required**:
|
||||
|
||||
- Email notifications for deadlines, status changes
|
||||
- In-app notifications
|
||||
- SMS notifications (optional)
|
||||
- Email templates
|
||||
- Notification preferences per user
|
||||
|
||||
### 📋 **8. Enhanced Project Management (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Basic project tracking
|
||||
**Required**:
|
||||
|
||||
- Gantt charts for project timelines
|
||||
- Resource allocation and management
|
||||
- Budget tracking per project
|
||||
- Document attachment system
|
||||
- Project templates
|
||||
- Milestone tracking
|
||||
- Dependencies between tasks
|
||||
|
||||
### 🔍 **9. Search & Filtering (LOW PRIORITY)**
|
||||
|
||||
**Current State**: Basic search implemented
|
||||
**Required**:
|
||||
|
||||
- Advanced search with filters
|
||||
- Full-text search
|
||||
- Saved search queries
|
||||
- Search autocomplete
|
||||
- Global search across all entities
|
||||
|
||||
### ⚡ **10. Performance & Scalability (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Good for small-medium datasets
|
||||
**Required**:
|
||||
|
||||
- Database optimization and indexing
|
||||
- Caching layer (Redis)
|
||||
- Image optimization
|
||||
- Lazy loading
|
||||
- Pagination for large datasets
|
||||
- Background job processing
|
||||
|
||||
### 📝 **11. Documentation & Help System (LOW PRIORITY)**
|
||||
|
||||
**Current State**: README.md only
|
||||
**Required**:
|
||||
|
||||
- User manual/documentation
|
||||
- In-app help system
|
||||
- API documentation
|
||||
- Video tutorials
|
||||
- FAQ section
|
||||
|
||||
### 🧪 **12. Testing & Quality Assurance (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Testing frameworks set up but no tests
|
||||
**Required**:
|
||||
|
||||
- Unit tests for all components
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
- Performance testing
|
||||
- Accessibility testing
|
||||
- Code coverage reports
|
||||
|
||||
### 🚀 **13. DevOps & Deployment (MEDIUM PRIORITY)**
|
||||
|
||||
**Current State**: Docker setup exists
|
||||
**Required**:
|
||||
|
||||
- CI/CD pipeline
|
||||
- Production deployment strategy
|
||||
- Environment management (dev, staging, prod)
|
||||
- Monitoring and logging
|
||||
- Error tracking (Sentry)
|
||||
- Health checks
|
||||
|
||||
### 🎨 **14. UI/UX Improvements (LOW PRIORITY)**
|
||||
|
||||
**Current State**: Clean, functional interface
|
||||
**Required**:
|
||||
|
||||
- Dark mode support
|
||||
- Customizable themes
|
||||
- Accessibility improvements (WCAG compliance)
|
||||
- Keyboard navigation
|
||||
- Better loading states
|
||||
- Drag and drop functionality
|
||||
**eProjektant Wastpol** is a comprehensive, enterprise-grade project management system for construction and design projects. The application has evolved significantly and now includes production-ready features across all core areas.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority Levels
|
||||
## ✅ Completed Features (v0.1.1)
|
||||
|
||||
### Phase 1: Security & Stability (Weeks 1-4)
|
||||
### Core Business Logic
|
||||
- ✅ **Project Management** - Full CRUD with lifecycle tracking (registered → in_progress → fulfilled)
|
||||
- ✅ **Contract Management** - Customer contracts with multi-project support
|
||||
- ✅ **Task System** - Template-based tasks, task sets, custom tasks per project
|
||||
- ✅ **Task Sets** - Pre-configured task groups for quick project setup
|
||||
- ✅ **Contact Management** - Full contact database with project relationships
|
||||
- ✅ **Notes System** - Project and task notes with markdown support, system-generated notes
|
||||
- ✅ **File Attachments** - Generic file system for contracts, projects, and tasks (10MB limit)
|
||||
|
||||
1. Authentication system
|
||||
2. Authorization and role management
|
||||
3. Input validation and security
|
||||
4. Backup system
|
||||
5. Basic testing coverage
|
||||
### Advanced Features
|
||||
- ✅ **Document Generation** - DOCX template system with variable substitution
|
||||
- ✅ **GIS Integration** - Leaflet maps with 8 base layers and 6 overlay layers (Polish geoportal)
|
||||
- ✅ **CardDAV Sync** - Bi-directional contact sync with Radicale
|
||||
- ✅ **Route Planning** - Route optimization for project locations
|
||||
- ✅ **Notification System** - In-app notifications (6 types, 4 priority levels)
|
||||
- ✅ **Field History Tracking** - Audit trail for critical field changes
|
||||
- ✅ **Automated Backups** - Daily database backups (keeps last 30)
|
||||
- ✅ **Due Date Reminders** - Automated notifications 3 days and 1 day before deadlines
|
||||
- ✅ **Excel Export** - Projects export grouped by status
|
||||
- ✅ **Cron Job Management** - Admin interface for scheduled tasks
|
||||
|
||||
### Phase 2: Core Features (Weeks 5-8)
|
||||
### Security & Authentication
|
||||
- ✅ **NextAuth.js v5** - Modern authentication with credentials provider
|
||||
- ✅ **5-Role System** - Admin, Project Manager, Team Lead, User, Read Only
|
||||
- ✅ **Account Security** - Account lockout after 5 failed attempts (15-min lock)
|
||||
- ✅ **Password Hashing** - bcryptjs with salt
|
||||
- ✅ **Session Management** - Secure SQLite session store
|
||||
- ✅ **Route Protection** - Middleware-based authentication
|
||||
- ✅ **API Authorization** - Per-route auth middleware (withReadAuth, withUserAuth, withAdminAuth)
|
||||
- ✅ **Password Reset Tokens** - Database table ready (UI pending)
|
||||
- ✅ **Audit Logging** - Comprehensive tracking of all user actions
|
||||
- ✅ **Input Validation** - Zod schemas for all inputs
|
||||
- ✅ **Failed Login Tracking** - IP address and user agent logging
|
||||
|
||||
1. Advanced reporting
|
||||
2. Mobile optimization
|
||||
3. Notification system
|
||||
4. Enhanced project management features
|
||||
### UI/UX
|
||||
- ✅ **Dark/Light Theme** - User-selectable with system preference detection
|
||||
- ✅ **Responsive Design** - Mobile-first, optimized for all screen sizes
|
||||
- ✅ **40+ Components** - Reusable component library
|
||||
- ✅ **Internationalization** - Polish and English (1200+ translation keys)
|
||||
- ✅ **Advanced Search** - Real-time search with filters (status, type, customer, assigned user)
|
||||
- ✅ **Loading States** - Skeletons, spinners, progress indicators
|
||||
- ✅ **Toast Notifications** - Non-intrusive user feedback
|
||||
- ✅ **Badge System** - Color-coded status indicators
|
||||
- ✅ **Modal Dialogs** - Clean form interfaces
|
||||
- ✅ **Drag & Drop** - File upload with drag-and-drop
|
||||
|
||||
### Phase 3: Professional Features (Weeks 9-12)
|
||||
### Infrastructure
|
||||
- ✅ **Docker Deployment** - Multi-stage builds with git-based deployment
|
||||
- ✅ **SQLite Database** - Auto-initializing with migration system
|
||||
- ✅ **60+ API Endpoints** - RESTful API with consistent structure
|
||||
- ✅ **Database Indexes** - Performance optimization for common queries
|
||||
- ✅ **Error Handling** - Try-catch blocks with user-friendly messages
|
||||
- ✅ **Environment Config** - .env support for all configurations
|
||||
- ✅ **Cron Integration** - Linux cron for scheduled tasks
|
||||
- ✅ **Volume Persistence** - Data, uploads, templates, backups
|
||||
|
||||
1. API integrations
|
||||
2. Performance optimization
|
||||
3. Advanced UI features
|
||||
4. Documentation
|
||||
### Testing & Documentation
|
||||
- ✅ **Testing Framework** - Jest, Playwright, Testing Library configured
|
||||
- ✅ **E2E Tests** - Project workflow tests implemented
|
||||
- ✅ **Comprehensive README** - Full documentation with examples
|
||||
- ✅ **API Documentation** - Inline documentation in README
|
||||
- ✅ **Code Structure Docs** - Detailed project structure documentation
|
||||
|
||||
### Phase 4: Scale & Polish (Weeks 13-16)
|
||||
|
||||
1. DevOps improvements
|
||||
2. Comprehensive testing
|
||||
3. Advanced analytics
|
||||
4. Third-party integrations
|
||||
|
||||
---
|
||||
|
||||
## Immediate Next Steps (Recommended Order)
|
||||
## 🎯 High Priority Features (Next 3 Months)
|
||||
|
||||
1. **Set up Authentication**
|
||||
### 🔐 **1. Enhanced Security (Weeks 1-2)**
|
||||
|
||||
- Install NextAuth.js or implement custom auth
|
||||
- Create user management system
|
||||
- Add login/logout functionality
|
||||
**Status**: Security foundations complete, need additional hardening
|
||||
**Completed**: ✅ Authentication, Authorization, Audit Logging, Input Validation
|
||||
**Remaining**:
|
||||
- [ ] CSRF protection middleware
|
||||
- [ ] Rate limiting for API endpoints (rate-limiter-flexible)
|
||||
- [ ] Security headers (helmet.js or custom middleware)
|
||||
- [ ] Sanitization for user-generated content (DOMPurify)
|
||||
- [ ] API key authentication for external integrations
|
||||
- [ ] Two-factor authentication (2FA) support
|
||||
|
||||
2. **Implement Input Validation**
|
||||
|
||||
- Add Zod or Joi for schema validation
|
||||
- Protect all API endpoints
|
||||
- Add error handling
|
||||
|
||||
3. **Create Backup System**
|
||||
|
||||
- Implement database backup scripts
|
||||
- Set up automated backups
|
||||
- Create recovery procedures
|
||||
|
||||
4. **Add Basic Tests**
|
||||
|
||||
- Write unit tests for critical functions
|
||||
- Add integration tests for API routes
|
||||
- Set up test automation
|
||||
|
||||
5. **Implement Reporting**
|
||||
- Add Chart.js for visualizations
|
||||
- Create project timeline reports
|
||||
- Add export functionality
|
||||
**Estimated Time**: 2 weeks
|
||||
**Impact**: HIGH - Critical for production security
|
||||
|
||||
---
|
||||
|
||||
## Technology Recommendations
|
||||
### 📊 **2. Advanced Reporting & Analytics (Weeks 3-6)**
|
||||
|
||||
### Authentication
|
||||
**Status**: Libraries installed, basic stats done, need full UI
|
||||
**Completed**: ✅ Recharts, jsPDF, ExcelJS, basic dashboard, Excel export
|
||||
**Remaining**:
|
||||
- [ ] Interactive Gantt charts for project timelines
|
||||
- [ ] Budget vs. actual spend tracking and reports
|
||||
- [ ] Task completion analytics dashboard
|
||||
- [ ] Project performance metrics (on-time %, cost overruns)
|
||||
- [ ] Custom report builder with filters
|
||||
- [ ] PDF report generation with charts
|
||||
- [ ] Financial reports by contract/project
|
||||
- [ ] Resource utilization reports
|
||||
- [ ] Export to multiple formats (PDF, Excel, CSV)
|
||||
|
||||
- **NextAuth.js** - For easy authentication setup
|
||||
- **Prisma** - For better database management (optional upgrade from better-sqlite3)
|
||||
**Estimated Time**: 3-4 weeks
|
||||
**Impact**: HIGH - Core business need
|
||||
|
||||
### Security
|
||||
---
|
||||
|
||||
- **Zod** - Runtime type checking and validation
|
||||
### 📧 **3. Email Integration (Weeks 7-8)**
|
||||
|
||||
**Status**: Password reset table exists, no email sending
|
||||
**Completed**: ✅ Password reset token schema
|
||||
**Remaining**:
|
||||
- [ ] SMTP configuration (Nodemailer)
|
||||
- [ ] Email templates (HTML/Text)
|
||||
- [ ] Password reset flow UI
|
||||
- [ ] Email verification for new users
|
||||
- [ ] Project deadline reminders via email
|
||||
- [ ] Task assignment notifications via email
|
||||
- [ ] Daily/weekly digest emails
|
||||
- [ ] Email preferences per user
|
||||
- [ ] Email queue for bulk sending
|
||||
|
||||
**Estimated Time**: 2 weeks
|
||||
**Impact**: HIGH - Essential for user management and notifications
|
||||
|
||||
---
|
||||
|
||||
### 📱 **4. Progressive Web App (PWA) (Weeks 9-10)**
|
||||
|
||||
**Status**: Responsive design complete, no PWA features
|
||||
**Completed**: ✅ Responsive UI, mobile-optimized
|
||||
**Remaining**:
|
||||
- [ ] Service worker implementation
|
||||
- [ ] App manifest (manifest.json)
|
||||
- [ ] Offline functionality for viewing data
|
||||
- [ ] Install prompt for mobile devices
|
||||
- [ ] Push notification support (optional)
|
||||
- [ ] Offline data sync strategy
|
||||
- [ ] App icons for different platforms
|
||||
|
||||
**Estimated Time**: 2 weeks
|
||||
**Impact**: MEDIUM - Enhances mobile experience
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Medium Priority Features (Months 4-6)
|
||||
|
||||
### 🔗 **5. External Integrations & API**
|
||||
|
||||
**Status**: Internal API complete, no external integrations
|
||||
**Remaining**:
|
||||
- [ ] REST API documentation (Swagger/OpenAPI)
|
||||
- [ ] API versioning (/api/v1/)
|
||||
- [ ] Webhook system for external notifications
|
||||
- [ ] Integration with accounting software (optional)
|
||||
- [ ] Integration with CRM systems (optional)
|
||||
- [ ] OAuth2 provider for third-party apps
|
||||
- [ ] API rate limiting per client
|
||||
- [ ] API key management UI
|
||||
|
||||
**Estimated Time**: 3-4 weeks
|
||||
**Impact**: MEDIUM - Expands system capabilities
|
||||
|
||||
---
|
||||
|
||||
### 📋 **6. Enhanced Project Management**
|
||||
|
||||
**Status**: Basic tracking complete, missing advanced features
|
||||
**Completed**: ✅ Basic project CRUD, task tracking, status management
|
||||
**Remaining**:
|
||||
- [ ] Gantt chart visualization (react-gantt-timeline or similar)
|
||||
- [ ] Project dependencies and critical path
|
||||
- [ ] Milestone tracking with visual timeline
|
||||
- [ ] Resource allocation and workload management
|
||||
- [ ] Project templates (save project as template)
|
||||
- [ ] Budget tracking per project with variance analysis
|
||||
- [ ] Time tracking for tasks
|
||||
- [ ] Project cloning functionality
|
||||
- [ ] Bulk operations (status updates, assignments)
|
||||
|
||||
**Estimated Time**: 4-5 weeks
|
||||
**Impact**: MEDIUM - Professional project management features
|
||||
|
||||
---
|
||||
|
||||
### ⚡ **7. Performance & Scalability**
|
||||
|
||||
**Status**: Good for current load, optimization needed for scale
|
||||
**Completed**: ✅ Database indexes on key fields
|
||||
**Remaining**:
|
||||
- [ ] Redis caching layer for sessions and frequent queries
|
||||
- [ ] Image optimization and lazy loading
|
||||
- [ ] Virtual scrolling for large lists
|
||||
- [ ] Pagination for all list views
|
||||
- [ ] Database query optimization analysis
|
||||
- [ ] Background job processing (Bull/BullMQ)
|
||||
- [ ] CDN integration for static assets
|
||||
- [ ] Database connection pooling
|
||||
- [ ] Response compression (gzip)
|
||||
- [ ] Client-side caching strategy
|
||||
|
||||
**Estimated Time**: 3 weeks
|
||||
**Impact**: MEDIUM - Needed as data grows
|
||||
|
||||
---
|
||||
|
||||
### 🧪 **8. Comprehensive Testing**
|
||||
|
||||
**Status**: Framework set up, minimal test coverage
|
||||
**Completed**: ✅ Jest, Playwright, Testing Library configured, basic E2E tests
|
||||
**Remaining**:
|
||||
- [ ] Unit tests for all lib functions (target: 80% coverage)
|
||||
- [ ] Integration tests for all API endpoints
|
||||
- [ ] Component tests for all React components
|
||||
- [ ] E2E tests for critical user flows (login, create project, assign task)
|
||||
- [ ] Performance testing (load testing)
|
||||
- [ ] Accessibility testing (axe-core, WCAG compliance)
|
||||
- [ ] Visual regression testing (Percy/Chromatic)
|
||||
- [ ] CI/CD pipeline integration
|
||||
- [ ] Automated test runs on PR
|
||||
|
||||
**Estimated Time**: 4-5 weeks
|
||||
**Impact**: MEDIUM - Quality assurance
|
||||
|
||||
---
|
||||
|
||||
## 📌 Low Priority / Nice-to-Have (Months 6+)
|
||||
|
||||
### 🎨 **9. Advanced UI/UX**
|
||||
|
||||
**Status**: Functional and clean, room for polish
|
||||
**Completed**: ✅ Dark/light theme, responsive design, component library
|
||||
**Remaining**:
|
||||
- [ ] Customizable color themes per user
|
||||
- [ ] Keyboard shortcuts and navigation
|
||||
- [ ] Accessibility improvements (ARIA labels, focus management)
|
||||
- [ ] Animation and micro-interactions
|
||||
- [ ] Better empty states with illustrations
|
||||
- [ ] Improved error messages with helpful actions
|
||||
- [ ] Onboarding tour for new users
|
||||
- [ ] Customizable dashboard widgets
|
||||
|
||||
**Estimated Time**: 3-4 weeks
|
||||
**Impact**: LOW - Polish and user experience
|
||||
|
||||
---
|
||||
|
||||
### 🔍 **10. Advanced Search**
|
||||
|
||||
**Status**: Basic search working, can be enhanced
|
||||
**Completed**: ✅ Real-time search with filters
|
||||
**Remaining**:
|
||||
- [ ] Full-text search across all entities (FTS5 in SQLite)
|
||||
- [ ] Saved search queries per user
|
||||
- [ ] Search autocomplete with suggestions
|
||||
- [ ] Global search (Cmd+K interface)
|
||||
- [ ] Search history
|
||||
- [ ] Advanced filters (date ranges, custom fields)
|
||||
- [ ] Search results highlighting
|
||||
|
||||
**Estimated Time**: 2-3 weeks
|
||||
**Impact**: LOW - User convenience
|
||||
|
||||
---
|
||||
|
||||
### 📝 **11. Documentation & Help**
|
||||
|
||||
**Status**: README complete, no in-app help
|
||||
**Completed**: ✅ Comprehensive README, API documentation, project structure docs
|
||||
**Remaining**:
|
||||
- [ ] In-app help system with tooltips
|
||||
- [ ] User manual (PDF/Web)
|
||||
- [ ] Video tutorials for common tasks
|
||||
- [ ] FAQ section
|
||||
- [ ] Changelog page
|
||||
- [ ] Developer documentation
|
||||
- [ ] API usage examples
|
||||
- [ ] Troubleshooting guide
|
||||
|
||||
**Estimated Time**: 3 weeks
|
||||
**Impact**: LOW - User support
|
||||
|
||||
---
|
||||
|
||||
### 🚀 **12. DevOps & Monitoring**
|
||||
|
||||
**Status**: Docker deployed, basic logging
|
||||
**Completed**: ✅ Docker multi-stage builds, docker-compose, git-based deployment
|
||||
**Remaining**:
|
||||
- [ ] CI/CD pipeline (GitHub Actions/GitLab CI)
|
||||
- [ ] Automated deployment to staging/production
|
||||
- [ ] Health check endpoints
|
||||
- [ ] Application monitoring (Prometheus/Grafana)
|
||||
- [ ] Error tracking (Sentry)
|
||||
- [ ] Log aggregation (ELK/Loki)
|
||||
- [ ] Uptime monitoring
|
||||
- [ ] Performance monitoring (APM)
|
||||
- [ ] Automated database migrations on deploy
|
||||
- [ ] Blue-green deployment strategy
|
||||
|
||||
**Estimated Time**: 4 weeks
|
||||
**Impact**: LOW - Operations maturity
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📅 Implementation Roadmap
|
||||
|
||||
### **Phase 1: Security & Critical Features (Months 1-2)**
|
||||
|
||||
**Week 1-2: Security Hardening**
|
||||
- [ ] CSRF protection middleware
|
||||
- [ ] Rate limiting implementation
|
||||
- [ ] Security headers
|
||||
- [ ] Content sanitization
|
||||
|
||||
**Week 3-6: Reporting & Analytics**
|
||||
- [ ] Gantt chart component
|
||||
- [ ] Budget tracking UI
|
||||
- [ ] Task analytics dashboard
|
||||
- [ ] PDF report generation
|
||||
- [ ] Custom report builder
|
||||
|
||||
**Week 7-8: Email System**
|
||||
- [ ] SMTP setup and configuration
|
||||
- [ ] Email templates (password reset, notifications)
|
||||
- [ ] Password reset flow UI
|
||||
- [ ] Email notification preferences
|
||||
|
||||
**Deliverable**: Production-secure system with comprehensive reporting
|
||||
|
||||
---
|
||||
|
||||
### **Phase 2: User Experience & Performance (Months 3-4)**
|
||||
|
||||
**Week 9-10: Progressive Web App**
|
||||
- [ ] Service worker setup
|
||||
- [ ] App manifest
|
||||
- [ ] Offline caching strategy
|
||||
- [ ] Install prompts
|
||||
|
||||
**Week 11-13: Performance Optimization**
|
||||
- [ ] Redis caching layer
|
||||
- [ ] Pagination implementation
|
||||
- [ ] Image optimization
|
||||
- [ ] Query optimization
|
||||
- [ ] Background job processing
|
||||
|
||||
**Week 14-16: Testing Coverage**
|
||||
- [ ] Unit tests for lib functions
|
||||
- [ ] API endpoint tests
|
||||
- [ ] Component tests
|
||||
- [ ] E2E test expansion
|
||||
- [ ] CI/CD integration
|
||||
|
||||
**Deliverable**: Fast, mobile-ready app with solid test coverage
|
||||
|
||||
---
|
||||
|
||||
### **Phase 3: Professional Features (Months 5-6)**
|
||||
|
||||
**Week 17-20: Advanced Project Management**
|
||||
- [ ] Gantt chart timeline view
|
||||
- [ ] Project templates
|
||||
- [ ] Resource allocation
|
||||
- [ ] Milestone tracking
|
||||
- [ ] Project dependencies
|
||||
|
||||
**Week 21-23: External Integrations**
|
||||
- [ ] API documentation (Swagger)
|
||||
- [ ] Webhook system
|
||||
- [ ] API versioning
|
||||
- [ ] Third-party integration framework
|
||||
|
||||
**Week 24-26: Polish & Documentation**
|
||||
- [ ] UI/UX improvements
|
||||
- [ ] In-app help system
|
||||
- [ ] User manual
|
||||
- [ ] Video tutorials
|
||||
|
||||
**Deliverable**: Enterprise-ready system with external integration capabilities
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Immediate Next Steps (This Month)
|
||||
|
||||
### Week 1-2: Security Hardening
|
||||
1. **CSRF Protection**
|
||||
- Install `csurf` or implement custom CSRF middleware
|
||||
- Add CSRF tokens to all forms
|
||||
- Configure CSRF validation for POST/PUT/DELETE
|
||||
|
||||
2. **Rate Limiting**
|
||||
- Install `express-rate-limit` or `rate-limiter-flexible`
|
||||
- Apply to login endpoints (prevent brute force)
|
||||
- Apply to API routes (prevent abuse)
|
||||
- Configure different limits for authenticated vs. unauthenticated
|
||||
|
||||
3. **Security Headers**
|
||||
- Install `helmet` or implement custom headers
|
||||
- Configure CSP (Content Security Policy)
|
||||
- Add X-Frame-Options, X-Content-Type-Options
|
||||
- HSTS for HTTPS
|
||||
|
||||
4. **Content Sanitization**
|
||||
- Install `DOMPurify` for client-side
|
||||
- Sanitize user input in notes and descriptions
|
||||
- Prevent XSS in markdown rendering
|
||||
|
||||
---
|
||||
|
||||
## 📊 Feature Completion Status
|
||||
|
||||
| Category | Completion | Priority | Next Steps |
|
||||
|----------|-----------|----------|------------|
|
||||
| **Core Business Logic** | 95% ✅ | - | Minor enhancements |
|
||||
| **Authentication & Security** | 80% 🟨 | HIGH | CSRF, rate limiting, headers |
|
||||
| **Notifications** | 90% ✅ | MEDIUM | Email integration |
|
||||
| **File Management** | 100% ✅ | - | Complete |
|
||||
| **GIS/Mapping** | 100% ✅ | - | Complete |
|
||||
| **Reporting** | 40% 🟥 | HIGH | Advanced reports, Gantt charts |
|
||||
| **Testing** | 30% 🟥 | MEDIUM | Expand test coverage |
|
||||
| **Documentation** | 90% ✅ | LOW | In-app help |
|
||||
| **Performance** | 70% 🟨 | MEDIUM | Caching, optimization |
|
||||
| **Mobile/PWA** | 60% 🟨 | MEDIUM | Service workers, offline |
|
||||
| **Integrations** | 20% 🟥 | LOW | API docs, webhooks |
|
||||
|
||||
**Legend**: ✅ Complete (80%+) | 🟨 In Progress (50-79%) | 🟥 Needs Work (<50%)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technology Stack & Recommendations
|
||||
|
||||
### Currently Implemented ✅
|
||||
- **Next.js 15.1** - App Router, React 19
|
||||
- **SQLite** - better-sqlite3 with auto-migrations
|
||||
- **NextAuth.js v5** - Authentication with 5 roles
|
||||
- **Tailwind CSS** - Styling with dark/light themes
|
||||
- **Zod** - Input validation
|
||||
- **bcryptjs** - Password hashing
|
||||
- **rate-limiter-flexible** - Rate limiting
|
||||
|
||||
### Reporting
|
||||
|
||||
- **Chart.js** or **Recharts** - Data visualization
|
||||
- **jsPDF** - PDF generation
|
||||
- **xlsx** - Excel export
|
||||
|
||||
### Notifications
|
||||
- **Leaflet** - Maps with Proj4
|
||||
- **Recharts** - Charts (underutilized)
|
||||
- **jsPDF** - PDF generation (underutilized)
|
||||
- **ExcelJS** - Excel export
|
||||
- **Docxtemplater** - DOCX generation
|
||||
- **date-fns** - Date handling
|
||||
- **Jest + Playwright** - Testing frameworks
|
||||
|
||||
### Recommended Additions
|
||||
- **helmet** or custom middleware - Security headers
|
||||
- **rate-limiter-flexible** - API rate limiting
|
||||
- **DOMPurify** - XSS prevention
|
||||
- **Nodemailer** - Email sending
|
||||
- **Socket.io** - Real-time notifications
|
||||
|
||||
### Testing
|
||||
|
||||
- **Redis** - Caching layer (optional, for scale)
|
||||
- **Bull/BullMQ** - Background job processing (optional)
|
||||
- **Swagger/OpenAPI** - API documentation
|
||||
- **Sentry** - Error tracking (production)
|
||||
- **MSW** - API mocking for tests
|
||||
- **Testing Library** - Component testing
|
||||
- **Faker.js** - Test data generation
|
||||
- **Storybook** - Component documentation (optional)
|
||||
|
||||
### Not Recommended (Keep Simple)
|
||||
- **Prisma** - Current SQLite + migrations work well
|
||||
- **TypeScript** - JSDoc provides type hints, migration not urgent
|
||||
- **GraphQL** - REST API sufficient for current needs
|
||||
- **Microservices** - Monolith appropriate for current scale
|
||||
|
||||
---
|
||||
|
||||
## Current Strengths
|
||||
## 💡 Current Strengths
|
||||
|
||||
1. **Well-structured codebase** with clear separation of concerns
|
||||
2. **Modern tech stack** (Next.js, React, Tailwind)
|
||||
3. **Good database design** with proper relationships
|
||||
4. **Responsive UI** with professional appearance
|
||||
5. **Docker support** for easy deployment
|
||||
6. **Map integration** with multiple layers
|
||||
7. **Modular components** that are reusable
|
||||
1. ✅ **Production-Ready Foundation** - Core features complete and tested
|
||||
2. ✅ **Comprehensive Security** - Authentication, authorization, audit logging
|
||||
3. ✅ **Well-Structured Codebase** - Clear separation of concerns, modular
|
||||
4. ✅ **Modern Tech Stack** - Latest Next.js, React 19, Tailwind CSS
|
||||
5. ✅ **Enterprise Features** - Multi-role system, notifications, file management
|
||||
6. ✅ **Polish Localization** - Full i18n with 1200+ translations
|
||||
7. ✅ **GIS Integration** - Advanced mapping with Polish cadastral data
|
||||
8. ✅ **Automated Workflows** - Cron jobs, backups, reminders
|
||||
9. ✅ **Docker Deployment** - Production-ready containerization
|
||||
10. ✅ **Extensible Architecture** - Easy to add features
|
||||
11. ✅ **Comprehensive Documentation** - README, API docs, project structure
|
||||
12. ✅ **Professional UI** - Clean, responsive, accessible
|
||||
|
||||
---
|
||||
|
||||
## Estimated Development Time
|
||||
## 📈 Estimated Development Timeline
|
||||
|
||||
- **Minimum Viable Professional App**: 8-12 weeks
|
||||
- **Full-featured Professional App**: 16-20 weeks
|
||||
- **Enterprise-grade Application**: 24-30 weeks
|
||||
### Minimum Production Deployment (Current State)
|
||||
**Status**: ✅ **READY NOW**
|
||||
- All core features implemented
|
||||
- Security foundations in place
|
||||
- Docker deployment ready
|
||||
- **Recommended**: Add CSRF + rate limiting before production
|
||||
|
||||
This assessment is based on a single developer working full-time. Team development could reduce these timelines significantly.
|
||||
### Enhanced Security & Reporting
|
||||
**Timeline**: 6-8 weeks
|
||||
**Features**: CSRF, rate limiting, Gantt charts, advanced reports, email
|
||||
|
||||
### Full Professional System
|
||||
**Timeline**: 12-16 weeks
|
||||
**Features**: + PWA, performance optimization, testing, integrations
|
||||
|
||||
### Enterprise-Grade Application
|
||||
**Timeline**: 20-26 weeks
|
||||
**Features**: + Advanced project management, monitoring, comprehensive tests
|
||||
|
||||
*Timelines based on 1 full-time developer. Team development reduces by 40-60%.*
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Metrics
|
||||
|
||||
### Current Metrics (v0.1.1)
|
||||
- ✅ 60+ API endpoints
|
||||
- ✅ 40+ React components
|
||||
- ✅ 5 user roles with granular permissions
|
||||
- ✅ 1200+ i18n translation keys
|
||||
- ✅ 14 database tables with relationships
|
||||
- ✅ 8 map base layers + 6 overlays
|
||||
- ✅ 6 notification types
|
||||
- ✅ 100% database migration coverage
|
||||
- ⚠️ ~15% test coverage (needs improvement)
|
||||
|
||||
### Target Metrics (v0.2.0)
|
||||
- [ ] 80%+ test coverage
|
||||
- [ ] <2s average page load
|
||||
- [ ] <100ms API response time
|
||||
- [ ] 100% API documentation coverage
|
||||
- [ ] A+ security grade (Mozilla Observatory)
|
||||
- [ ] WCAG 2.1 AA compliance
|
||||
- [ ] PWA installability
|
||||
|
||||
---
|
||||
|
||||
## 📞 Questions & Decisions Needed
|
||||
|
||||
1. **Email Provider**: Which SMTP service? (SendGrid, AWS SES, self-hosted?)
|
||||
2. **Error Tracking**: Implement Sentry or similar?
|
||||
3. **Caching Strategy**: Add Redis or stick with in-memory?
|
||||
4. **CI/CD Platform**: GitHub Actions, GitLab CI, or other?
|
||||
5. **Monitoring**: Self-hosted (Prometheus) or SaaS (DataDog)?
|
||||
6. **Database**: Stick with SQLite or migrate to PostgreSQL for scale?
|
||||
7. **TypeScript**: Migrate from JSDoc or keep as-is?
|
||||
|
||||
---
|
||||
|
||||
**Version 0.1.1 Status**: Production-ready foundation with room for enhancement
|
||||
**Next Major Version (0.2.0)**: Security hardening + Advanced reporting
|
||||
**Version 1.0.0 Target**: Q2 2026 - Full professional system
|
||||
|
||||
61
backup-db.mjs
Normal file
61
backup-db.mjs
Normal file
@@ -0,0 +1,61 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = "data/database.sqlite";
|
||||
const backupDir = "backups";
|
||||
|
||||
// Ensure backup directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir);
|
||||
}
|
||||
|
||||
// Generate timestamp for backup filename
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const backupPath = path.join(backupDir, `backup-${timestamp}.sqlite`);
|
||||
|
||||
// Create backup by copying the database file
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
|
||||
console.log(`✅ Backup created: ${backupPath}`);
|
||||
|
||||
// Send notification if configured
|
||||
try {
|
||||
const { createNotification, NOTIFICATION_TYPES } = await import("./src/lib/notifications.js");
|
||||
const db = (await import("./src/lib/db.js")).default;
|
||||
|
||||
const setting = db.prepare("SELECT value FROM settings WHERE key = 'backup_notification_user_id'").get();
|
||||
if (setting && setting.value) {
|
||||
const userId = setting.value;
|
||||
await createNotification({
|
||||
userId,
|
||||
type: NOTIFICATION_TYPES.SYSTEM_ANNOUNCEMENT,
|
||||
title: "Database Backup Completed",
|
||||
message: `Daily database backup completed successfully. Backup file: ${backupPath}`,
|
||||
priority: "normal"
|
||||
});
|
||||
console.log(`📢 Notification sent to user ${userId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send backup notification:", error);
|
||||
}
|
||||
|
||||
// Cleanup: keep only last 30 backups
|
||||
const files = fs.readdirSync(backupDir)
|
||||
.filter(f => f.startsWith('backup-'))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(backupDir, f),
|
||||
mtime: fs.statSync(path.join(backupDir, f)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime); // Sort by modification time, newest first
|
||||
|
||||
if (files.length > 30) {
|
||||
const toDelete = files.slice(30);
|
||||
toDelete.forEach(f => {
|
||||
fs.unlinkSync(f.path);
|
||||
console.log(`🗑️ Deleted old backup: ${f.name}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📁 Total backups kept: ${Math.min(files.length, 30)}`);
|
||||
@@ -1,56 +0,0 @@
|
||||
import { readFileSync } from "fs";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
// Check database directly
|
||||
const dbPath = "./data/database.sqlite";
|
||||
const db = new Database(dbPath);
|
||||
|
||||
console.log("Checking audit logs table...\n");
|
||||
|
||||
// Check table schema
|
||||
const schema = db
|
||||
.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_logs'"
|
||||
)
|
||||
.get();
|
||||
console.log("Table schema:");
|
||||
console.log(schema?.sql || "Table not found");
|
||||
|
||||
console.log("\n" + "=".repeat(50) + "\n");
|
||||
|
||||
// Get some audit logs
|
||||
const logs = db
|
||||
.prepare("SELECT * FROM audit_logs ORDER BY timestamp DESC LIMIT 5")
|
||||
.all();
|
||||
console.log(`Found ${logs.length} audit log entries:`);
|
||||
|
||||
logs.forEach((log, index) => {
|
||||
console.log(`\n${index + 1}. ID: ${log.id}`);
|
||||
console.log(` Timestamp: ${log.timestamp}`);
|
||||
console.log(` User ID: ${log.user_id || "NULL"}`);
|
||||
console.log(` Action: ${log.action}`);
|
||||
console.log(` Resource Type: ${log.resource_type}`);
|
||||
console.log(` Resource ID: ${log.resource_id || "N/A"}`);
|
||||
console.log(` IP Address: ${log.ip_address || "N/A"}`);
|
||||
console.log(` User Agent: ${log.user_agent || "N/A"}`);
|
||||
console.log(` Details: ${log.details || "NULL"}`);
|
||||
console.log(` Details type: ${typeof log.details}`);
|
||||
});
|
||||
|
||||
// Count null user_ids
|
||||
const nullUserCount = db
|
||||
.prepare("SELECT COUNT(*) as count FROM audit_logs WHERE user_id IS NULL")
|
||||
.get();
|
||||
const totalCount = db.prepare("SELECT COUNT(*) as count FROM audit_logs").get();
|
||||
|
||||
console.log(`\n${"=".repeat(50)}`);
|
||||
console.log(`Total audit logs: ${totalCount.count}`);
|
||||
console.log(`Logs with NULL user_id: ${nullUserCount.count}`);
|
||||
console.log(
|
||||
`Percentage with NULL user_id: ${(
|
||||
(nullUserCount.count / totalCount.count) *
|
||||
100
|
||||
).toFixed(2)}%`
|
||||
);
|
||||
|
||||
db.close();
|
||||
@@ -1,13 +0,0 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
console.log("Checking projects table structure:");
|
||||
const tableInfo = db.prepare("PRAGMA table_info(projects)").all();
|
||||
console.log(JSON.stringify(tableInfo, null, 2));
|
||||
|
||||
// Check if created_at and updated_at columns exist
|
||||
const hasCreatedAt = tableInfo.some((col) => col.name === "created_at");
|
||||
const hasUpdatedAt = tableInfo.some((col) => col.name === "updated_at");
|
||||
|
||||
console.log("\nColumn existence check:");
|
||||
console.log("created_at exists:", hasCreatedAt);
|
||||
console.log("updated_at exists:", hasUpdatedAt);
|
||||
@@ -1,5 +0,0 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
console.log("Current projects table structure:");
|
||||
const tableInfo = db.prepare("PRAGMA table_info(projects)").all();
|
||||
console.log(JSON.stringify(tableInfo, null, 2));
|
||||
@@ -1,32 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
// Check table structures first
|
||||
console.log("Users table structure:");
|
||||
const usersSchema = db.prepare("PRAGMA table_info(users)").all();
|
||||
console.log(usersSchema);
|
||||
|
||||
console.log("\nProjects table structure:");
|
||||
const projectsSchema = db.prepare("PRAGMA table_info(projects)").all();
|
||||
console.log(projectsSchema);
|
||||
|
||||
// Check if there are any projects
|
||||
const projects = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT p.*,
|
||||
creator.name as created_by_name,
|
||||
assignee.name as assigned_to_name
|
||||
FROM projects p
|
||||
LEFT JOIN users creator ON p.created_by = creator.id
|
||||
LEFT JOIN users assignee ON p.assigned_to = assignee.id
|
||||
LIMIT 5
|
||||
`
|
||||
)
|
||||
.all();
|
||||
|
||||
console.log("\nProjects in database:");
|
||||
console.log(JSON.stringify(projects, null, 2));
|
||||
|
||||
db.close();
|
||||
@@ -1,10 +0,0 @@
|
||||
import db from "./src/lib/db.js";
|
||||
|
||||
console.log("Database schema for notes table:");
|
||||
console.log(db.prepare("PRAGMA table_info(notes)").all());
|
||||
|
||||
console.log("\nDatabase schema for project_tasks table:");
|
||||
console.log(db.prepare("PRAGMA table_info(project_tasks)").all());
|
||||
|
||||
console.log("\nSample notes to check is_system column:");
|
||||
console.log(db.prepare("SELECT * FROM notes LIMIT 5").all());
|
||||
@@ -1,25 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
console.log("Project Tasks table structure:");
|
||||
const projectTasksSchema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||
console.table(projectTasksSchema);
|
||||
|
||||
console.log("\nSample project tasks with user tracking:");
|
||||
const tasks = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT pt.*,
|
||||
creator.name as created_by_name,
|
||||
assignee.name as assigned_to_name
|
||||
FROM project_tasks pt
|
||||
LEFT JOIN users creator ON pt.created_by = creator.id
|
||||
LEFT JOIN users assignee ON pt.assigned_to = assignee.id
|
||||
LIMIT 3
|
||||
`
|
||||
)
|
||||
.all();
|
||||
console.table(tasks);
|
||||
|
||||
db.close();
|
||||
@@ -1,355 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const ComprehensivePolishMap = dynamic(
|
||||
() => import('../../components/ui/ComprehensivePolishMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
export default function ComprehensivePolishMapPage() {
|
||||
const [selectedLocation, setSelectedLocation] = useState('krakow');
|
||||
|
||||
// Different locations to test the layers
|
||||
const locations = {
|
||||
krakow: {
|
||||
center: [50.0647, 19.9450],
|
||||
zoom: 14,
|
||||
name: "Kraków",
|
||||
description: "Historic city center with good cadastral data coverage"
|
||||
},
|
||||
warsaw: {
|
||||
center: [52.2297, 21.0122],
|
||||
zoom: 14,
|
||||
name: "Warszawa",
|
||||
description: "Capital city with extensive planning data"
|
||||
},
|
||||
gdansk: {
|
||||
center: [54.3520, 18.6466],
|
||||
zoom: 14,
|
||||
name: "Gdańsk",
|
||||
description: "Port city with detailed property boundaries"
|
||||
},
|
||||
wroclaw: {
|
||||
center: [51.1079, 17.0385],
|
||||
zoom: 14,
|
||||
name: "Wrocław",
|
||||
description: "University city with good orthophoto coverage"
|
||||
},
|
||||
poznan: {
|
||||
center: [52.4064, 16.9252],
|
||||
zoom: 14,
|
||||
name: "Poznań",
|
||||
description: "Industrial center with road network data"
|
||||
}
|
||||
};
|
||||
|
||||
const currentLocation = locations[selectedLocation];
|
||||
|
||||
// Test markers for selected location
|
||||
const testMarkers = [
|
||||
{
|
||||
position: currentLocation.center,
|
||||
popup: `${currentLocation.name} - ${currentLocation.description}`
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-6">
|
||||
🇵🇱 Comprehensive Polish Geospatial Data Platform
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-green-800 mb-3">
|
||||
All Polish Layers Implementation Complete! 🎉
|
||||
</h2>
|
||||
<p className="text-green-700 mb-4">
|
||||
This comprehensive map includes all layers from your OpenLayers implementation,
|
||||
converted to work seamlessly with your Leaflet-based React/Next.js project.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-800">Base Layers:</strong>
|
||||
<ul className="mt-1 text-green-700">
|
||||
<li>• Polish Orthophoto (Standard & High Resolution)</li>
|
||||
<li>• OpenStreetMap, Google Maps, Esri Satellite</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-800">Overlay Layers:</strong>
|
||||
<ul className="mt-1 text-green-700">
|
||||
<li>• Cadastral Data, Spatial Planning</li>
|
||||
<li>• LP-Portal Roads, Street Names, Parcels, Surveys</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location Selector */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">
|
||||
🎯 Select Test Location:
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{Object.entries(locations).map(([key, location]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedLocation(key)}
|
||||
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
selectedLocation === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{location.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
<strong>Current:</strong> {currentLocation.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">
|
||||
Interactive Map: {currentLocation.name}
|
||||
</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
Use the layer control (top-right) to toggle between base layers and enable overlay layers.
|
||||
Combine orthophoto with cadastral data for detailed property analysis.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[700px]">
|
||||
<ComprehensivePolishMap
|
||||
key={selectedLocation} // Force re-render when location changes
|
||||
center={currentLocation.center}
|
||||
zoom={currentLocation.zoom}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer Information */}
|
||||
<div className="mt-8 grid md:grid-cols-2 gap-6">
|
||||
{/* Base Layers */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
🗺️ Base Layers
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-green-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Polish Orthophoto (Standard)</strong>
|
||||
<p className="text-gray-600 mt-1">High-quality aerial imagery from Polish Geoportal</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-emerald-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Polish Orthophoto (High Resolution)</strong>
|
||||
<p className="text-gray-600 mt-1">Ultra-high resolution aerial imagery for detailed analysis</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-blue-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>OpenStreetMap</strong>
|
||||
<p className="text-gray-600 mt-1">Community-driven map data</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-red-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>Google Maps</strong>
|
||||
<p className="text-gray-600 mt-1">Satellite imagery and road overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overlay Layers */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center">
|
||||
📊 Overlay Layers
|
||||
</h3> <div className="space-y-3 text-sm">
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-orange-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>📋 Polish Cadastral Data</strong>
|
||||
<p className="text-gray-600 mt-1">Property boundaries, parcels, and building outlines</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 80% - Semi-transparent overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-purple-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🏗️ Polish Spatial Planning</strong>
|
||||
<p className="text-gray-600 mt-1">Zoning data and urban planning information</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 70% - Semi-transparent overlay</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-teal-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🛣️ LP-Portal Roads</strong>
|
||||
<p className="text-gray-600 mt-1">Detailed road network data</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 90% - Mostly opaque for visibility</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-indigo-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>🏷️ LP-Portal Street Names</strong>
|
||||
<p className="text-gray-600 mt-1">Street names and road descriptions</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 100% - Fully opaque for readability</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start">
|
||||
<span className="w-4 h-4 bg-pink-500 rounded-full mr-3 mt-0.5 flex-shrink-0"></span>
|
||||
<div>
|
||||
<strong>📐 LP-Portal Parcels & Surveys</strong>
|
||||
<p className="text-gray-600 mt-1">Property parcels and survey markers</p>
|
||||
<p className="text-xs text-gray-500">Opacity: 60-80% - Variable transparency</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transparency Information */}
|
||||
<div className="mt-8 bg-green-50 border border-green-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-green-800 mb-4">
|
||||
🎨 Layer Transparency Handling
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-3">Base Layers (Opaque):</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>Polish Orthophoto</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Google Satellite/Roads</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opaque</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-green-700 mb-3">Overlay Layers (Transparent):</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span>📋 Cadastral Data</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🏗️ Spatial Planning</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">70% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🛣️ Roads</span>
|
||||
<span className="bg-blue-200 px-2 py-1 rounded text-xs">90% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>🏷️ Street Names</span>
|
||||
<span className="bg-green-200 px-2 py-1 rounded text-xs">100% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>📐 Parcels</span>
|
||||
<span className="bg-orange-200 px-2 py-1 rounded text-xs">60% Opacity</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>📍 Survey Markers</span>
|
||||
<span className="bg-yellow-200 px-2 py-1 rounded text-xs">80% Opacity</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-green-100 rounded">
|
||||
<p className="text-green-800 text-sm">
|
||||
<strong>Smart Transparency:</strong> Each overlay layer has been optimized with appropriate transparency levels.
|
||||
Property boundaries are semi-transparent (60-80%) so you can see the underlying imagery,
|
||||
while text labels are fully opaque (100%) for maximum readability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Guide */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4">
|
||||
📋 How to Use This Comprehensive Map
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-700 mb-2">Basic Navigation:</h4>
|
||||
<ul className="text-blue-600 space-y-1 text-sm">
|
||||
<li>• Use mouse wheel to zoom in/out</li>
|
||||
<li>• Click and drag to pan around</li>
|
||||
<li>• Use layer control (top-right) to switch layers</li>
|
||||
<li>• Select different Polish cities above to test</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-700 mb-2">Advanced Features:</h4>
|
||||
<ul className="text-blue-600 space-y-1 text-sm">
|
||||
<li>• Combine orthophoto with cadastral overlay</li>
|
||||
<li>• Enable multiple overlays simultaneously</li>
|
||||
<li>• Use high-resolution orthophoto for detail work</li>
|
||||
<li>• Compare with Google/OSM base layers</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Implementation */}
|
||||
<div className="mt-8 bg-gray-50 border border-gray-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
⚙️ Technical Implementation Details
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">WMTS Integration:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• Proper KVP URL construction</li>
|
||||
<li>• EPSG:3857 coordinate system</li>
|
||||
<li>• Standard and high-res orthophoto</li>
|
||||
<li>• Multiple format support (JPEG/PNG)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">WMS Overlays:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• Polish government services</li>
|
||||
<li>• LP-Portal municipal data</li>
|
||||
<li>• Transparent overlay support</li>
|
||||
<li>• Multiple layer combinations</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">React/Leaflet:</h4>
|
||||
<ul className="text-gray-600 space-y-1">
|
||||
<li>• React-Leaflet component integration</li>
|
||||
<li>• Dynamic layer switching</li>
|
||||
<li>• Responsive design</li>
|
||||
<li>• Performance optimized</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// Temporarily disabled debug pages during build
|
||||
// These pages are for development/testing purposes only
|
||||
// To re-enable, rename this file to layout.js
|
||||
|
||||
export default function DebugLayout({ children }) {
|
||||
return children;
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -1,113 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const DebugPolishOrthophotoMap = dynamic(
|
||||
() => import('../../components/ui/DebugPolishOrthophotoMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
export const dynamicParams = true;
|
||||
|
||||
export default function DebugPolishOrthophotoPage() {
|
||||
// Test marker in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Test Location"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Debug Polish Geoportal Orthophoto
|
||||
</h1>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">
|
||||
Debug Mode Active
|
||||
</h2>
|
||||
<p className="text-red-700">
|
||||
This page tests multiple URL formats for Polish Geoportal orthophoto tiles.
|
||||
Check the browser console and the debug panel on the map for network request information.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Debug Map with Multiple Orthophoto Options</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
Try switching between different Polish orthophoto options using the layer control.
|
||||
Google layers are included as working references.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<DebugPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
URL Formats Being Tested:
|
||||
</h3>
|
||||
<div className="space-y-4 text-sm"> <div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 1 (WMTS KVP EPSG:3857):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Standard Web Mercator projection</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 2 (WMTS KVP EPSG:2180):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:2180&TILEMATRIX=EPSG:2180:{z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Polish coordinate system</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 3 (Alternative TILEMATRIXSET):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=GoogleMapsCompatible&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg
|
||||
</code>
|
||||
<span className="text-gray-600">Google Maps compatible matrix</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Option 4 (PNG format):</strong>
|
||||
<code className="block mt-1 text-xs">
|
||||
?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/png
|
||||
</code>
|
||||
<span className="text-gray-600">PNG format instead of JPEG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Debug Instructions:
|
||||
</h3>
|
||||
<ol className="text-yellow-700 space-y-2">
|
||||
<li><strong>1.</strong> Open browser Developer Tools (F12) and go to Network tab</li>
|
||||
<li><strong>2.</strong> Switch between different Polish orthophoto options in the layer control</li>
|
||||
<li><strong>3.</strong> Look for requests to geoportal.gov.pl in the Network tab</li>
|
||||
<li><strong>4.</strong> Check the debug panel on the map for request/response info</li>
|
||||
<li><strong>5.</strong> Note which options return 200 OK vs 404/403 errors</li>
|
||||
<li><strong>6.</strong> Compare with working Google layers</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const ImprovedPolishOrthophotoMap = dynamic(
|
||||
() => import('../../components/ui/ImprovedPolishOrthophotoMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
export default function ImprovedPolishOrthophotoPage() {
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Testing WMTS"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Improved Polish WMTS Implementation
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-lg font-semibold text-green-800 mb-2">
|
||||
Custom WMTS Layer Implementation
|
||||
</h2>
|
||||
<p className="text-green-700">
|
||||
This version uses a custom WMTS layer that properly constructs KVP URLs based on the GetCapabilities response.
|
||||
Check the debug panel on the map to see the actual requests being made.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Custom WMTS Layer with Proper KVP URLs</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
This implementation builds proper WMTS GetTile requests with all required parameters.
|
||||
Monitor the debug panel and browser network tab for request details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<ImprovedPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]}
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
WMTS Parameters Being Tested:
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Tile Matrix Sets Available:</strong>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• EPSG:3857 (Web Mercator)</li>
|
||||
<li>• EPSG:4326 (WGS84)</li>
|
||||
<li>• EPSG:2180 (Polish National Grid)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-3 rounded">
|
||||
<strong>Formats Available:</strong>
|
||||
<ul className="mt-2 space-y-1">
|
||||
<li>• image/jpeg (default)</li>
|
||||
<li>• image/png</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
Testing Instructions:
|
||||
</h3>
|
||||
<ol className="text-blue-700 space-y-2">
|
||||
<li><strong>1.</strong> Open Browser Developer Tools (F12) → Network tab</li>
|
||||
<li><strong>2.</strong> Filter by "geoportal.gov.pl" to see WMTS requests</li>
|
||||
<li><strong>3.</strong> Switch between different Polish WMTS options</li>
|
||||
<li><strong>4.</strong> Check if requests return 200 OK or error codes</li>
|
||||
<li><strong>5.</strong> Compare with Google Satellite (known working)</li>
|
||||
<li><strong>6.</strong> Monitor the debug panel for request URLs</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Expected Behavior:
|
||||
</h3>
|
||||
<p className="text-yellow-700">
|
||||
If the Polish orthophoto tiles appear, you should see aerial imagery of Poland.
|
||||
If they don't load, check the network requests - they should show proper WMTS GetTile URLs
|
||||
with all required parameters (SERVICE, REQUEST, LAYER, TILEMATRIXSET, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const PolishOrthophotoMap = dynamic(
|
||||
() => import('../../components/ui/PolishOrthophotoMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
const AdvancedPolishOrthophotoMap = dynamic(
|
||||
() => import('../../components/ui/AdvancedPolishOrthophotoMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
export default function PolishOrthophotoTestPage() {
|
||||
const [activeMap, setActiveMap] = useState('basic');
|
||||
|
||||
// Test markers - various locations in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Main Market Square"
|
||||
},
|
||||
{
|
||||
position: [52.2297, 21.0122], // Warsaw
|
||||
popup: "Warszawa - Palace of Culture and Science"
|
||||
},
|
||||
{
|
||||
position: [54.3520, 18.6466], // Gdansk
|
||||
popup: "Gdańsk - Old Town"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Polish Geoportal Orthophoto Integration
|
||||
</h1>
|
||||
|
||||
{/* Map Type Selector */}
|
||||
<div className="mb-6 bg-white rounded-lg shadow-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">
|
||||
Choose Map Implementation:
|
||||
</h2>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => setActiveMap('basic')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeMap === 'basic'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Basic Polish Orthophoto
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveMap('advanced')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeMap === 'advanced'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
Advanced with WMS Overlays
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map Container */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{activeMap === 'basic'
|
||||
? 'Basic Polish Orthophoto Map'
|
||||
: 'Advanced Polish Orthophoto with WMS Overlays'
|
||||
}
|
||||
</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
{activeMap === 'basic'
|
||||
? 'Demonstrates working Polish Geoportal orthophoto tiles with multiple base layer options.'
|
||||
: 'Advanced version includes Polish cadastral data (działki) and spatial planning (MPZT) as overlay layers.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
{activeMap === 'basic' ? (
|
||||
<PolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedPolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Overview */}
|
||||
<div className="mt-8 grid md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Basic Map Features:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
Polish Geoportal Orthophoto (Working)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
|
||||
OpenStreetMap base layer
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
|
||||
Google Satellite imagery
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
|
||||
Google Roads overlay
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
|
||||
Esri World Imagery
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Advanced Map Features:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
Standard & High Resolution Orthophoto
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-orange-500 rounded-full mr-3"></span>
|
||||
Polish Cadastral Data (WMS)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-teal-500 rounded-full mr-3"></span>
|
||||
Spatial Planning Data (MPZT)
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-indigo-500 rounded-full mr-3"></span>
|
||||
Overlay layer support
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-pink-500 rounded-full mr-3"></span>
|
||||
Multiple base layers
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Implementation Details */}
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Technical Implementation:
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Key Improvements:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Uses REST tile service instead of WMTS for better compatibility</li>
|
||||
<li>• Proper tile size (512px) with zoomOffset=-1</li>
|
||||
<li>• proj4 integration for EPSG:2180 coordinate system</li>
|
||||
<li>• Multiple fallback layers for reliability</li>
|
||||
<li>• WMS overlay support for cadastral data</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-700 mb-2">Based on OpenLayers Code:</h4>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>• Converted from OpenLayers to Leaflet implementation</li>
|
||||
<li>• Maintains same layer structure and URLs</li>
|
||||
<li>• Includes Polish projection definitions</li>
|
||||
<li>• Compatible with existing React/Next.js setup</li>
|
||||
<li>• Extensible for additional WMS services</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
How to Use:
|
||||
</h3>
|
||||
<div className="text-blue-700 space-y-2">
|
||||
<p><strong>1.</strong> Use the layer control (top-right) to switch between base layers</p>
|
||||
<p><strong>2.</strong> In advanced mode, enable overlay layers for cadastral/planning data</p>
|
||||
<p><strong>3.</strong> Click on markers to see location information</p>
|
||||
<p><strong>4.</strong> Zoom in to see high-resolution orthophoto details</p>
|
||||
<p><strong>5.</strong> Combine orthophoto with cadastral overlay for property boundaries</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const PolishOrthophotoMap = dynamic(
|
||||
() => import('../../components/ui/PolishOrthophotoMap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <div className="flex items-center justify-center h-96">Loading map...</div>
|
||||
}
|
||||
);
|
||||
|
||||
export default function TestPolishOrthophotoPage() {
|
||||
// Test markers - various locations in Poland
|
||||
const testMarkers = [
|
||||
{
|
||||
position: [50.0647, 19.9450], // Krakow
|
||||
popup: "Kraków - Main Market Square"
|
||||
},
|
||||
{
|
||||
position: [52.2297, 21.0122], // Warsaw
|
||||
popup: "Warszawa - Palace of Culture and Science"
|
||||
},
|
||||
{
|
||||
position: [54.3520, 18.6466], // Gdansk
|
||||
popup: "Gdańsk - Old Town"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">
|
||||
Polish Geoportal Orthophoto Map Test
|
||||
</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="p-4 bg-blue-600 text-white">
|
||||
<h2 className="text-xl font-semibold">Interactive Map with Polish Orthophoto</h2>
|
||||
<p className="text-blue-100 mt-2">
|
||||
This map demonstrates working Polish Geoportal orthophoto tiles.
|
||||
Use the layer control (top-right) to switch between different map layers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-96 md:h-[600px]">
|
||||
<PolishOrthophotoMap
|
||||
center={[50.0647, 19.9450]} // Centered on Krakow
|
||||
zoom={12}
|
||||
markers={testMarkers}
|
||||
showLayerControl={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
Map Layers Available:
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-green-500 rounded-full mr-3"></span>
|
||||
<strong>Polish Geoportal Orthophoto:</strong> High-resolution aerial imagery from Polish Geoportal
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-blue-500 rounded-full mr-3"></span>
|
||||
<strong>OpenStreetMap:</strong> Standard OpenStreetMap tiles
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-red-500 rounded-full mr-3"></span>
|
||||
<strong>Google Satellite:</strong> Google satellite imagery
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-yellow-500 rounded-full mr-3"></span>
|
||||
<strong>Google Roads:</strong> Google road overlay
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="w-3 h-3 bg-purple-500 rounded-full mr-3"></span>
|
||||
<strong>Esri Satellite:</strong> Esri world imagery
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
Implementation Notes:
|
||||
</h3>
|
||||
<div className="text-yellow-700 space-y-2">
|
||||
<p>
|
||||
• The Polish Geoportal orthophoto uses REST tile service instead of WMTS for better compatibility
|
||||
</p>
|
||||
<p>
|
||||
• Tile size is set to 512px with zoomOffset=-1 for proper tile alignment
|
||||
</p>
|
||||
<p>
|
||||
• proj4 library is included for coordinate system transformations (EPSG:2180)
|
||||
</p>
|
||||
<p>
|
||||
• Multiple fallback layers are provided for comparison and reliability
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
// Debug file to test dropdown functionality
|
||||
console.log("Testing dropdown components...");
|
||||
|
||||
// Simple test to check if components are rendering
|
||||
const testTask = {
|
||||
id: 1,
|
||||
status: "pending",
|
||||
task_name: "Test Task",
|
||||
};
|
||||
|
||||
console.log("Test task:", testTask);
|
||||
@@ -1,49 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
console.log("Project Tasks table columns:");
|
||||
const projectTasksSchema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||
projectTasksSchema.forEach((col) => {
|
||||
console.log(
|
||||
`${col.name}: ${col.type} (${col.notnull ? "NOT NULL" : "NULL"})`
|
||||
);
|
||||
});
|
||||
|
||||
console.log("\nChecking if created_at and updated_at columns exist...");
|
||||
const hasCreatedAt = projectTasksSchema.some(
|
||||
(col) => col.name === "created_at"
|
||||
);
|
||||
const hasUpdatedAt = projectTasksSchema.some(
|
||||
(col) => col.name === "updated_at"
|
||||
);
|
||||
console.log("created_at exists:", hasCreatedAt);
|
||||
console.log("updated_at exists:", hasUpdatedAt);
|
||||
|
||||
// Let's try a simple insert to see what happens
|
||||
console.log("\nTesting manual insert...");
|
||||
try {
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO project_tasks (
|
||||
project_id, task_template_id, status, priority,
|
||||
created_by, assigned_to, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`
|
||||
)
|
||||
.run(1, 1, "pending", "normal", "test-user", "test-user");
|
||||
|
||||
console.log("Insert successful, ID:", result.lastInsertRowid);
|
||||
|
||||
// Clean up
|
||||
db.prepare("DELETE FROM project_tasks WHERE id = ?").run(
|
||||
result.lastInsertRowid
|
||||
);
|
||||
console.log("Test record cleaned up");
|
||||
} catch (error) {
|
||||
console.error("Insert failed:", error.message);
|
||||
}
|
||||
|
||||
db.close();
|
||||
57
deploy.bat
Normal file
57
deploy.bat
Normal file
@@ -0,0 +1,57 @@
|
||||
@echo off
|
||||
REM Production deployment script for Windows
|
||||
REM Usage: deploy.bat [git_repo_url] [branch] [commit_hash]
|
||||
|
||||
set GIT_REPO_URL=%1
|
||||
set GIT_BRANCH=%2
|
||||
if "%GIT_BRANCH%"=="" set GIT_BRANCH=ui-fix
|
||||
set GIT_COMMIT=%3
|
||||
|
||||
REM Check if .env.production exists
|
||||
if exist .env.production (
|
||||
echo Loading production environment variables...
|
||||
for /f "delims=" %%x in (.env.production) do (
|
||||
set "%%x"
|
||||
)
|
||||
) else (
|
||||
echo Warning: .env.production not found. Make sure environment variables are set!
|
||||
)
|
||||
|
||||
REM Validate critical environment variables
|
||||
if "%NEXTAUTH_SECRET%"=="" (
|
||||
echo ERROR: NEXTAUTH_SECRET must be set to a secure random string!
|
||||
echo Generate one with: openssl rand -base64 32
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
@REM if "%NEXTAUTH_SECRET%"=="YOUR_SUPER_SECURE_SECRET_KEY_HERE_AT_LEAST_32_CHARACTERS_LONG" (
|
||||
@REM echo ERROR: NEXTAUTH_SECRET must be changed from the default value!
|
||||
@REM echo Generate one with: openssl rand -base64 32
|
||||
@REM exit /b 1
|
||||
@REM )
|
||||
|
||||
if "%NEXTAUTH_URL%"=="" (
|
||||
echo ERROR: NEXTAUTH_URL must be set to your production URL!
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%GIT_REPO_URL%"=="" (
|
||||
echo Building from local files...
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
) else (
|
||||
echo Building from git repository: %GIT_REPO_URL%
|
||||
echo Branch: %GIT_BRANCH%
|
||||
if not "%GIT_COMMIT%"=="" echo Commit: %GIT_COMMIT%
|
||||
|
||||
set GIT_REPO_URL=%GIT_REPO_URL%
|
||||
set GIT_BRANCH=%GIT_BRANCH%
|
||||
set GIT_COMMIT=%GIT_COMMIT%
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
)
|
||||
|
||||
echo Starting production deployment...
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
echo Deployment completed successfully!
|
||||
echo Application is running at http://localhost:3001
|
||||
52
deploy.sh
Normal file
52
deploy.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Production deployment script
|
||||
# Usage: ./deploy.sh [git_repo_url] [branch] [commit_hash]
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
GIT_REPO_URL=${1:-""}
|
||||
GIT_BRANCH=${2:-"ui-fix"}
|
||||
GIT_COMMIT=${3:-""}
|
||||
|
||||
# Check if .env.production exists and source it
|
||||
if [ -f .env.production ]; then
|
||||
echo "Loading production environment variables..."
|
||||
export $(grep -v '^#' .env.production | xargs)
|
||||
else
|
||||
echo "Warning: .env.production not found. Make sure environment variables are set!"
|
||||
fi
|
||||
|
||||
# Validate critical environment variables
|
||||
# if [ -z "$NEXTAUTH_SECRET" ] || [ "$NEXTAUTH_SECRET" = "YOUR_SUPER_SECURE_SECRET_KEY_HERE_AT_LEAST_32_CHARACTERS_LONG" ]; then
|
||||
# echo "ERROR: NEXTAUTH_SECRET must be set to a secure random string!"
|
||||
# echo "Generate one with: openssl rand -base64 32"
|
||||
# exit 1
|
||||
# fi
|
||||
|
||||
if [ -z "$NEXTAUTH_URL" ]; then
|
||||
echo "ERROR: NEXTAUTH_URL must be set to your production URL!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$GIT_REPO_URL" ]; then
|
||||
echo "Building from local files..."
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
else
|
||||
echo "Building from git repository: $GIT_REPO_URL"
|
||||
echo "Branch: $GIT_BRANCH"
|
||||
if [ -n "$GIT_COMMIT" ]; then
|
||||
echo "Commit: $GIT_COMMIT"
|
||||
fi
|
||||
|
||||
GIT_REPO_URL=$GIT_REPO_URL GIT_BRANCH=$GIT_BRANCH GIT_COMMIT=$GIT_COMMIT \
|
||||
docker-compose -f docker-compose.prod.yml build
|
||||
fi
|
||||
|
||||
echo "Starting production deployment..."
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
|
||||
echo "Deployment completed successfully!"
|
||||
echo "Application is running at http://localhost:3001"
|
||||
25
docker-compose.prod.yml
Normal file
25
docker-compose.prod.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- GIT_REPO_URL=${GIT_REPO_URL}
|
||||
- GIT_BRANCH=${GIT_BRANCH:-main}
|
||||
- GIT_COMMIT=${GIT_COMMIT}
|
||||
ports:
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./uploads:/app/public/uploads
|
||||
- ./templates:/app/templates
|
||||
- ./backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=Europe/Warsaw
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-your-secret-key-generate-a-strong-random-string-at-least-32-characters}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-https://panel2.wastpol.pl}
|
||||
- AUTH_TRUST_HOST=true
|
||||
restart: unless-stopped
|
||||
@@ -2,12 +2,18 @@ version: "3.9"
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3001:3000"
|
||||
volumes:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./uploads:/app/public/uploads
|
||||
- ./templates:/app/templates
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- TZ=Europe/Warsaw
|
||||
|
||||
45
docker-entrypoint-dev.sh
Normal file
45
docker-entrypoint-dev.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Development container startup script
|
||||
# This runs when the development container starts
|
||||
|
||||
echo "🚀 Starting development environment..."
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure uploads directory structure exists
|
||||
mkdir -p /app/public/uploads/contracts
|
||||
mkdir -p /app/public/uploads/projects
|
||||
mkdir -p /app/public/uploads/tasks
|
||||
|
||||
# Ensure templates directory exists
|
||||
mkdir -p /app/templates
|
||||
|
||||
# Set proper permissions for uploads directory
|
||||
chmod -R 755 /app/public/uploads
|
||||
|
||||
# Set proper permissions for templates directory
|
||||
chmod -R 755 /app/templates
|
||||
|
||||
# Create admin account if it doesn't exist
|
||||
echo "🔧 Setting up admin account..."
|
||||
node scripts/create-admin.js
|
||||
|
||||
# Set up daily backup cron job (runs at 2 AM daily)
|
||||
echo "⏰ Setting up daily backup cron job..."
|
||||
echo "0 2 * * * cd /app && node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||
chmod 0644 /etc/cron.d/backup-cron
|
||||
crontab /etc/cron.d/backup-cron
|
||||
|
||||
# Set up daily due date reminders cron job (runs at 3 AM daily)
|
||||
echo "⏰ Setting up daily due date reminders cron job..."
|
||||
echo "0 3 * * * cd /app && node send-due-date-reminders.mjs >> /app/data/reminders.log 2>&1" > /etc/cron.d/reminders-cron
|
||||
chmod 0644 /etc/cron.d/reminders-cron
|
||||
crontab -l | cat - /etc/cron.d/reminders-cron > /tmp/crontab.tmp && crontab /tmp/crontab.tmp
|
||||
|
||||
service cron start
|
||||
|
||||
# Start the development server
|
||||
echo "✅ Starting development server..."
|
||||
exec npm run dev
|
||||
47
docker-entrypoint.sh
Normal file
47
docker-entrypoint.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Container startup script
|
||||
# This runs when the container starts, not during build
|
||||
|
||||
echo "🚀 Starting application..."
|
||||
|
||||
# Ensure data directory exists
|
||||
mkdir -p /app/data
|
||||
|
||||
# Ensure uploads directory structure exists
|
||||
mkdir -p /app/public/uploads/contracts
|
||||
mkdir -p /app/public/uploads/projects
|
||||
mkdir -p /app/public/uploads/tasks
|
||||
|
||||
# Ensure templates directory exists
|
||||
mkdir -p /app/templates
|
||||
|
||||
# Set proper permissions for uploads directory
|
||||
chmod -R 755 /app/public/uploads
|
||||
|
||||
# Set proper permissions for templates directory
|
||||
chmod -R 755 /app/templates
|
||||
|
||||
# Create admin account if it doesn't exist
|
||||
echo "🔧 Setting up admin account..."
|
||||
node scripts/create-admin.js
|
||||
|
||||
# Run any pending database migrations
|
||||
echo "🔄 Running database migrations..."
|
||||
./run-migrations.sh
|
||||
|
||||
# Set up daily backup cron job (runs at 2 AM daily)
|
||||
echo "⏰ Setting up daily backup cron job..."
|
||||
echo "0 2 * * * cd /app && /usr/local/bin/node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||
chmod 0644 /etc/cron.d/backup-cron
|
||||
crontab /etc/cron.d/backup-cron
|
||||
|
||||
# Set up daily due date reminders cron job (runs at 3 AM daily)
|
||||
echo "⏰ Setting up daily due date reminders cron job..."
|
||||
echo "0 3 * * * cd /app && /usr/local/bin/node send-due-date-reminders.mjs >> /app/data/reminders.log 2>&1" > /etc/cron.d/reminders-cron
|
||||
chmod 0644 /etc/cron.d/reminders-cron
|
||||
crontab -l | cat - /etc/cron.d/reminders-cron > /tmp/crontab.tmp && crontab /tmp/crontab.tmp
|
||||
|
||||
# Start the application
|
||||
echo "✅ Starting production server..."
|
||||
exec npm start
|
||||
87
docs/README.md
Normal file
87
docs/README.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Documentation Index
|
||||
|
||||
**eProjektant Wastpol** - Complete documentation directory
|
||||
|
||||
---
|
||||
|
||||
## 📚 Main Documentation
|
||||
|
||||
- **[Main README](../README.md)** - Project overview, installation, API reference, deployment
|
||||
- **[Roadmap](../ROADMAP.md)** - Development roadmap, feature priorities, timelines
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Feature Documentation
|
||||
|
||||
### Core Features
|
||||
- **[Contacts System](features/CONTACTS_SYSTEM.md)** - Contact management, CardDAV sync, project linking
|
||||
- **[DOCX Templates](features/DOCX_TEMPLATES.md)** - Document generation, available variables, examples
|
||||
- **[Radicale Sync](features/RADICALE_SYNC.md)** - CardDAV integration, automatic sync, troubleshooting
|
||||
- **[Route Planning](features/ROUTE_PLANNING.md)** - Route optimization, multi-point routing, ORS integration
|
||||
|
||||
### Map System
|
||||
- **[Map Layers](MAP_LAYERS.md)** - WMTS/WMS configuration, adding custom layers, Polish geoportal
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Documentation
|
||||
|
||||
- **[Advanced Deployment](deployment/ADVANCED_DEPLOYMENT.md)** - Detailed deployment strategies
|
||||
- **[Git-Based Deployment](deployment/GIT_DEPLOYMENT.md)** - Git repository deployment, CI/CD
|
||||
|
||||
---
|
||||
|
||||
## 📖 Quick Links by Topic
|
||||
|
||||
### Getting Started
|
||||
1. [Installation Guide](../README.md#-getting-started)
|
||||
2. [Environment Configuration](../README.md#configuration)
|
||||
3. [Creating Admin User](../README.md#getting-started)
|
||||
4. [Docker Setup](../README.md#-docker-commands)
|
||||
|
||||
### Development
|
||||
1. [Project Structure](../README.md#-project-structure)
|
||||
2. [Available Scripts](../README.md#-available-scripts)
|
||||
3. [Database Schema](../README.md#%EF%B8%8F-database-schema)
|
||||
4. [Testing](../README.md#-testing)
|
||||
|
||||
### Features
|
||||
1. [Authentication & Roles](../README.md#-security--authentication)
|
||||
2. [Project Management](../README.md#-project-management)
|
||||
3. [Task System](../README.md#-advanced-task-system)
|
||||
4. [Notifications](../README.md#-notification-system)
|
||||
5. [GIS/Mapping](MAP_LAYERS.md)
|
||||
6. [Document Generation](features/DOCX_TEMPLATES.md)
|
||||
7. [Contact Management](features/CONTACTS_SYSTEM.md)
|
||||
|
||||
### API
|
||||
1. [API Endpoints](../README.md#-api-endpoints)
|
||||
2. [Authentication Endpoints](../README.md#authentication)
|
||||
3. [Projects API](../README.md#projects)
|
||||
4. [Contacts API](../README.md#contacts)
|
||||
|
||||
### Deployment
|
||||
1. [Production Deployment](../README.md#-deployment)
|
||||
2. [Docker Deployment](deployment/ADVANCED_DEPLOYMENT.md)
|
||||
3. [Git-Based Deployment](deployment/GIT_DEPLOYMENT.md)
|
||||
4. [Environment Variables](../README.md#environment-variables)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
- **Database Issues**: See [README - Troubleshooting](../README.md#-troubleshooting)
|
||||
- **Map Layers**: See [Map Layers Guide](MAP_LAYERS.md)
|
||||
- **CardDAV Sync**: See [Radicale Sync](features/RADICALE_SYNC.md)
|
||||
- **Route Planning**: See [Route Planning Guide](features/ROUTE_PLANNING.md)
|
||||
|
||||
---
|
||||
|
||||
## 📝 Contributing
|
||||
|
||||
See [ROADMAP.md](../ROADMAP.md) for development priorities and [README - Contributing](../README.md#-contributing) for guidelines.
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 16, 2026
|
||||
**Version**: 0.1.1
|
||||
174
docs/features/CONTACTS_SYSTEM.md
Normal file
174
docs/features/CONTACTS_SYSTEM.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Contacts Management System
|
||||
|
||||
## Overview
|
||||
|
||||
A comprehensive contacts management system has been implemented to replace the simple text field for project contacts. This system allows you to:
|
||||
|
||||
- **Create and manage a centralized contact database**
|
||||
- **Link multiple contacts to each project**
|
||||
- **Categorize contacts** (Project contacts, Contractors, Offices, Suppliers, etc.)
|
||||
- **Track contact details** (name, phone, email, company, position)
|
||||
- **Set primary contacts** for projects
|
||||
- **Search and filter** contacts easily
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Database Schema
|
||||
|
||||
**New Tables:**
|
||||
|
||||
- **`contacts`** - Stores all contact information
|
||||
- `contact_id` (Primary Key)
|
||||
- `name`, `phone`, `email`, `company`, `position`
|
||||
- `contact_type` (project/contractor/office/supplier/other)
|
||||
- `notes`, `is_active`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
- **`project_contacts`** - Junction table linking projects to contacts (many-to-many)
|
||||
- `project_id`, `contact_id` (Composite Primary Key)
|
||||
- `relationship_type`, `is_primary`
|
||||
- `added_at`, `added_by`
|
||||
|
||||
### 2. API Endpoints
|
||||
|
||||
- **`GET /api/contacts`** - List all contacts (with filters)
|
||||
- **`POST /api/contacts`** - Create new contact
|
||||
- **`GET /api/contacts/[id]`** - Get contact details
|
||||
- **`PUT /api/contacts/[id]`** - Update contact
|
||||
- **`DELETE /api/contacts/[id]`** - Delete contact (soft/hard)
|
||||
- **`GET /api/projects/[id]/contacts`** - Get project's contacts
|
||||
- **`POST /api/projects/[id]/contacts`** - Link contact to project
|
||||
- **`DELETE /api/projects/[id]/contacts`** - Unlink contact from project
|
||||
- **`PATCH /api/projects/[id]/contacts`** - Set primary contact
|
||||
|
||||
### 3. UI Components
|
||||
|
||||
- **`ContactForm`** - Create/edit contact form
|
||||
- **`/contacts` page** - Full contacts management interface with:
|
||||
- Statistics dashboard
|
||||
- Search and filtering
|
||||
- Contact cards with quick actions
|
||||
- CRUD operations
|
||||
- **`ProjectContactSelector`** - Multi-contact selector for projects
|
||||
- View linked contacts
|
||||
- Add/remove contacts
|
||||
- Set primary contact
|
||||
- Real-time search
|
||||
|
||||
### 4. Integration
|
||||
|
||||
- **Navigation** - "Kontakty" link added to main navigation
|
||||
- **ProjectForm** - Contact text field replaced with `ProjectContactSelector`
|
||||
- **Translations** - Polish translations added to i18n
|
||||
- **Query Functions** - Comprehensive database query functions in `src/lib/queries/contacts.js`
|
||||
|
||||
## How to Use
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. **Run the migration script** to create the new tables:
|
||||
```bash
|
||||
node migrate-contacts.mjs
|
||||
```
|
||||
|
||||
2. **Start your development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Visit** `http://localhost:3000/contacts` to start adding contacts
|
||||
|
||||
### Managing Contacts
|
||||
|
||||
1. **Create Contacts**:
|
||||
- Go to `/contacts`
|
||||
- Click "Dodaj kontakt"
|
||||
- Fill in contact details
|
||||
- Select contact type (Project/Contractor/Office/Supplier/Other)
|
||||
|
||||
2. **Link Contacts to Projects**:
|
||||
- Edit any project
|
||||
- In the "Kontakty do projektu" section
|
||||
- Click "+ Dodaj kontakt"
|
||||
- Search and add contacts
|
||||
- Set one as primary if needed
|
||||
|
||||
3. **View Contact Details**:
|
||||
- Contacts page shows all contacts with:
|
||||
- Contact information (phone, email, company)
|
||||
- Number of linked projects
|
||||
- Contact type badges
|
||||
- Edit or delete contacts as needed
|
||||
|
||||
### Contact Types
|
||||
|
||||
- **Kontakt projektowy (Project)** - Project-specific contacts
|
||||
- **Wykonawca (Contractor)** - Construction contractors
|
||||
- **Urząd (Office)** - Government offices, municipalities
|
||||
- **Dostawca (Supplier)** - Material suppliers, vendors
|
||||
- **Inny (Other)** - Any other type of contact
|
||||
|
||||
### Features
|
||||
|
||||
- **Search** - Search by name, phone, email, or company
|
||||
- **Filter** - Filter by contact type
|
||||
- **Statistics** - See breakdown of contacts by type
|
||||
- **Multiple Contacts per Project** - Link as many contacts as needed
|
||||
- **Primary Contact** - Mark one contact as primary for each project
|
||||
- **Bidirectional Links** - See which projects a contact is linked to
|
||||
- **Soft Delete** - Deleted contacts are marked inactive, not removed
|
||||
|
||||
## Database Migration Notes
|
||||
|
||||
- The **old `contact` text field** in the `projects` table is still present
|
||||
- It hasn't been removed for backward compatibility
|
||||
- You can manually migrate old contact data by:
|
||||
1. Creating contacts from the old text data
|
||||
2. Linking them to the appropriate projects
|
||||
3. The old field will remain for reference
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── contacts/
|
||||
│ │ │ ├── route.js # List/Create contacts
|
||||
│ │ │ └── [id]/
|
||||
│ │ │ └── route.js # Get/Update/Delete contact
|
||||
│ │ └── projects/
|
||||
│ │ └── [id]/
|
||||
│ │ └── contacts/
|
||||
│ │ └── route.js # Link/unlink contacts to project
|
||||
│ └── contacts/
|
||||
│ └── page.js # Contacts management page
|
||||
├── components/
|
||||
│ ├── ContactForm.js # Contact form component
|
||||
│ └── ProjectContactSelector.js # Project contact selector
|
||||
└── lib/
|
||||
├── queries/
|
||||
│ └── contacts.js # Database query functions
|
||||
└── init-db.js # Database schema with new tables
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements you could add:
|
||||
|
||||
- Contact import/export (CSV, Excel)
|
||||
- Contact groups or tags
|
||||
- Contact activity history
|
||||
- Email integration
|
||||
- Contact notes and history
|
||||
- Duplicate contact detection
|
||||
- Contact merge functionality
|
||||
- Advanced relationship types
|
||||
- Contact sharing between projects
|
||||
- Contact reminders/follow-ups
|
||||
|
||||
## Support
|
||||
|
||||
The old contact text field remains in the database, so no existing data is lost. You can gradually migrate to the new system at your own pace.
|
||||
|
||||
Enjoy your new contacts management system! 🎉
|
||||
286
docs/features/DOCX_TEMPLATES.md
Normal file
286
docs/features/DOCX_TEMPLATES.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# DOCX Template System
|
||||
|
||||
This system allows you to generate DOCX documents by filling templates with project data.
|
||||
|
||||
## How to Create Templates
|
||||
|
||||
1. **Create a DOCX Template**: Use Microsoft Word or any DOCX editor to create your template.
|
||||
|
||||
2. **Add Placeholders**: Use single curly braces `{variableName}` to mark where data should be inserted. Available variables:
|
||||
|
||||
### Available Variables (with duplicates for repeated use)
|
||||
|
||||
#### Project Information
|
||||
- `{project_name}`, `{project_name_1}`, `{project_name_2}`, `{project_name_3}` - Project name
|
||||
- `{project_number}`, `{project_number_1}`, `{project_number_2}` - Project number
|
||||
- `{address}`, `{address_1}`, `{address_2}` - Project address
|
||||
- `{city}`, `{city_1}`, `{city_2}` - City
|
||||
- `{plot}` - Plot number
|
||||
- `{district}` - District
|
||||
- `{unit}` - Unit
|
||||
- `{investment_number}` - Investment number
|
||||
- `{wp}` - WP number
|
||||
- `{coordinates}` - GPS coordinates
|
||||
- `{notes}` - Project notes
|
||||
|
||||
#### Processed/Transformed Fields
|
||||
- `{investment_number_short}` - Last part of investment number after last dash (e.g., "1234567" from "I-BC-DE-1234567")
|
||||
- `{project_number_short}` - Last part of project number after last dash
|
||||
- `{project_name_upper}` - Project name in uppercase
|
||||
- `{project_name_lower}` - Project name in lowercase
|
||||
- `{city_upper}` - City name in uppercase
|
||||
- `{customer_upper}` - Customer name in uppercase
|
||||
|
||||
#### Contract Information
|
||||
- `{contract_number}` - Contract number
|
||||
- `{customer_contract_number}` - Customer contract number
|
||||
- `{customer}`, `{customer_1}`, `{customer_2}` - Customer name
|
||||
- `{investor}` - Investor name
|
||||
|
||||
#### Dates
|
||||
- `{finish_date}` - Finish date (formatted)
|
||||
- `{completion_date}` - Completion date (formatted)
|
||||
- `{today_date}` - Today's date
|
||||
|
||||
#### Project Type & Status
|
||||
- `{project_type}` - Project type (design/construction/design+construction)
|
||||
- `{project_status}` - Project status
|
||||
|
||||
#### Financial
|
||||
- `{wartosc_zlecenia}`, `{wartosc_zlecenia_1}`, `{wartosc_zlecenia_2}` - Contract value
|
||||
|
||||
#### Standard Custom Fields (Pre-filled but Editable)
|
||||
- `{zk}` - ZK field
|
||||
- `{nr_zk}` - ZK number
|
||||
- `{kabel}` - Cable information
|
||||
- `{dlugosc}` - Length
|
||||
- `{data_wykonania}` - Execution date
|
||||
- `{st_nr}` - Station number
|
||||
- `{obw}` - Circuit
|
||||
- `{wp_short}` - Short WP reference
|
||||
- `{plomba}` - Seal/plomb information
|
||||
|
||||
## Example Template Content
|
||||
|
||||
```
|
||||
Project Report
|
||||
|
||||
Project Name: {project_name} ({project_name_upper})
|
||||
Project Number: {project_number} (Short: {project_number_short})
|
||||
Location: {city_upper}, {address}
|
||||
|
||||
Investment Details:
|
||||
Full Investment Number: {investment_number}
|
||||
Short Investment Number: {investment_number_short}
|
||||
|
||||
Contract Details:
|
||||
Contract Number: {contract_number}
|
||||
Customer: {customer} ({customer_upper})
|
||||
Value: {wartosc_zlecenia} PLN
|
||||
|
||||
Custom Information:
|
||||
Meeting Notes: {meeting_notes}
|
||||
Special Instructions: {special_instructions}
|
||||
Additional Comments: {additional_comments}
|
||||
|
||||
Technical Details:
|
||||
ZK: {zk}
|
||||
ZK Number: {nr_zk}
|
||||
Cable: {kabel}
|
||||
Length: {dlugosc}
|
||||
Execution Date: {data_wykonania}
|
||||
Station Number: {st_nr}
|
||||
Circuit: {obw}
|
||||
WP Short: {wp_short}
|
||||
Seal: {plomba}
|
||||
|
||||
Primary Contact:
|
||||
Name: {primary_contact}
|
||||
Phone: {primary_contact_phone}
|
||||
Email: {primary_contact_email}
|
||||
|
||||
Generated on: {today_date}
|
||||
```
|
||||
|
||||
## Uploading Templates
|
||||
|
||||
1. Go to the Templates page (`/templates`)
|
||||
2. Click "Add Template"
|
||||
3. Provide a name and description
|
||||
4. Upload your DOCX file
|
||||
5. The template will be available for generating documents
|
||||
|
||||
## Generating Documents
|
||||
|
||||
1. Open any project page
|
||||
2. In the sidebar, find the "Generate Document" section
|
||||
3. Select a template from the dropdown
|
||||
4. **Optional**: Click "Pokaż dodatkowe pola" to add custom data
|
||||
5. Fill in the standard fields (zk, nr_zk, kabel, etc.) and any additional custom fields
|
||||
6. Click "Generate Document"
|
||||
7. The filled document will be downloaded automatically with filename: `{template_name}_{project_name}_{timestamp}.docx`
|
||||
|
||||
## Custom Data Fields
|
||||
|
||||
During document generation, you can add custom data that will be merged with the project data:
|
||||
|
||||
### Standard Fields (Pre-filled but Fully Editable)
|
||||
These fields are pre-filled with common names but can be modified or removed:
|
||||
- `zk`, `nr_zk`, `kabel`, `dlugosc`, `data_wykonania`, `st_nr`, `obw`, `wp_short`, `plomba`
|
||||
|
||||
### Additional Custom Fields
|
||||
- **Custom fields** override project data if they have the same name
|
||||
- Use descriptive names like `meeting_notes`, `special_instructions`, `custom_date`
|
||||
- Custom fields are available in templates as `{custom_field_name}`
|
||||
- Empty custom fields are ignored
|
||||
- All fields can be removed if not needed
|
||||
|
||||
### Example Custom Fields:
|
||||
- `meeting_notes`: "Please bring project documentation"
|
||||
- `special_instructions`: "Use company letterhead"
|
||||
- `custom_date`: "2025-01-15"
|
||||
- `additional_comments`: "Follow up required"
|
||||
|
||||
## Template Syntax
|
||||
|
||||
The system uses `docxtemplater` library which supports:
|
||||
- Simple variable replacement: `{variable}`
|
||||
- Loops: `{#contacts}{name}{/contacts}`
|
||||
- Conditions: `{#primary_contact}Primary: {name}{/primary_contact}`
|
||||
- Formatting and styling from your DOCX template is preserved
|
||||
|
||||
## Data Processing & Transformations
|
||||
|
||||
The system automatically provides processed versions of common fields:
|
||||
|
||||
- **Short codes**: `{investment_number_short}` extracts the last segment after dashes (e.g., "1234567" from "I-BC-DE-1234567")
|
||||
- **Case transformations**: `{project_name_upper}`, `{city_upper}`, `{customer_upper}` for uppercase versions
|
||||
- **Duplicate fields**: Multiple versions of the same field for repeated use (`{project_name_1}`, `{project_name_2}`, etc.)
|
||||
|
||||
If you need additional transformations (like extracting different parts of codes, custom formatting, calculations, etc.), please let us know and we can add them to the system.
|
||||
|
||||
## Tips
|
||||
|
||||
- Test your templates with sample data first
|
||||
- Use descriptive variable names
|
||||
- Keep formatting simple for best results
|
||||
- Save templates with `.docx` extension only
|
||||
- Maximum file size: 10MB
|
||||
- **For repeated information**: If you need the same data to appear multiple times, create unique placeholders like `{project_name_header}` and `{project_name_footer}` and provide the same value for both
|
||||
|
||||
## Storage & Persistence
|
||||
|
||||
Templates are stored in two locations for persistence in Docker environments:
|
||||
|
||||
### Database Storage
|
||||
- **Location**: `data/database.sqlite` (table: `docx_templates`)
|
||||
- **Content**: Template metadata (name, description, file paths, timestamps)
|
||||
- **Persistence**: Handled by Docker volume mount `./data:/app/data`
|
||||
|
||||
### File Storage
|
||||
- **Location**: `templates/` (host) → `/app/templates/` (container)
|
||||
- **Content**: Actual DOCX template files
|
||||
- **Persistence**: Handled by Docker volume mount `./templates:/app/templates`
|
||||
- **Web Access**: Files are served via `/api/templates/download/{filename}`
|
||||
|
||||
### Docker Volume Mounts
|
||||
Both development and production Docker setups include volume mounts to ensure template persistence across container restarts:
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- ./data:/app/data # Database
|
||||
- ./templates:/app/templates # Template files
|
||||
- ./uploads:/app/public/uploads # Other uploads
|
||||
- ./backups:/app/backups # Backup files
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Problem: "Duplicate tag" error during generation**
|
||||
- **Cause**: Using the same placeholder multiple times (e.g., `{project_name}` twice)
|
||||
- **Solution**: Use numbered variants like `{project_name}`, `{project_name_1}`, `{project_name_2}` OR add the same value to custom fields with different names
|
||||
|
||||
**Problem: Template not rendering correctly**
|
||||
- **Cause**: Invalid placeholder syntax
|
||||
- **Solution**: Ensure all placeholders use single curly braces `{variable}` (not double `{{}}`)
|
||||
- **Verify**: Check for typos in variable names
|
||||
|
||||
**Problem: Missing data in generated document**
|
||||
- **Cause**: Project missing required fields or custom data not provided
|
||||
- **Solution**: Fill in all required project information or provide custom data during generation
|
||||
- **Check**: Review project details before generating
|
||||
|
||||
**Problem: Formatting lost in generated document**
|
||||
- **Cause**: Complex Word formatting or incompatible styles
|
||||
- **Solution**:
|
||||
- Simplify template formatting
|
||||
- Avoid complex tables or text boxes
|
||||
- Use basic styles (bold, italic, underline work best)
|
||||
- Test with minimal formatting first
|
||||
|
||||
**Problem: Generated file not downloading**
|
||||
- **Cause**: Browser popup blocker or network issue
|
||||
- **Solution**:
|
||||
- Allow popups for this site
|
||||
- Check browser console for errors (F12)
|
||||
- Try different browser
|
||||
- Check file size < 10MB
|
||||
|
||||
**Problem: Template upload fails**
|
||||
- **Cause**: File too large or invalid format
|
||||
- **Solution**:
|
||||
- Ensure file is .docx format (not .doc)
|
||||
- File size must be under 10MB
|
||||
- Re-save file in Word to fix corruption
|
||||
- Check file isn't password-protected
|
||||
|
||||
**Problem: Custom fields not appearing**
|
||||
- **Cause**: Field name mismatch between template and custom data
|
||||
- **Solution**:
|
||||
- Ensure exact match (case-sensitive)
|
||||
- Example: `{meeting_notes}` in template requires `meeting_notes` in custom data
|
||||
- Check for spaces in field names
|
||||
|
||||
**Problem: Dates not formatted correctly**
|
||||
- **Cause**: Date format differences
|
||||
- **Solution**: Dates are auto-formatted as YYYY-MM-DD
|
||||
- **Tip**: Use `{today_date}` for current date
|
||||
|
||||
### Getting Help
|
||||
|
||||
If you encounter other issues:
|
||||
1. Check browser console (F12) for error messages
|
||||
2. Verify template file is valid .docx
|
||||
3. Test with simpler template first
|
||||
4. Contact system administrator with error details
|
||||
|
||||
---
|
||||
|
||||
## 📋 Quick Reference
|
||||
|
||||
### File Limits
|
||||
- Maximum template size: 10MB
|
||||
- Supported format: .docx only
|
||||
- Unlimited templates per project
|
||||
|
||||
### Available Endpoints
|
||||
- `GET /api/templates` - List all templates
|
||||
- `POST /api/templates` - Upload new template
|
||||
- `POST /api/templates/generate` - Generate document
|
||||
- `GET /api/templates/download/{filename}` - Download template
|
||||
|
||||
### Best Practices
|
||||
✅ Test templates with sample data first
|
||||
✅ Use descriptive placeholder names
|
||||
✅ Keep formatting simple
|
||||
✅ Use numbered variants for repeated data
|
||||
✅ Provide meaningful template descriptions
|
||||
❌ Don't use same placeholder twice
|
||||
❌ Don't use complex Word features (macros, forms)
|
||||
❌ Don't upload non-.docx files
|
||||
|
||||
---
|
||||
|
||||
**See Also**: [Main README](../../README.md#-document-generation) | [API Documentation](../../README.md#templates-docx)
|
||||
351
docs/features/RADICALE_SYNC.md
Normal file
351
docs/features/RADICALE_SYNC.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# Radicale CardDAV Sync Integration
|
||||
|
||||
This application now automatically syncs contacts to a Radicale CardDAV server whenever contacts are created, updated, or deleted.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Automatic Sync** - Contacts are automatically synced when created or updated
|
||||
- ✅ **Automatic Deletion** - Contacts are removed from Radicale when soft/hard deleted
|
||||
- ✅ **Non-Blocking** - Sync happens asynchronously without slowing down the API
|
||||
- ✅ **Optional** - Sync is disabled by default, enable by configuring environment variables
|
||||
- ✅ **VCARD 3.0** - Generates standard VCARD format with full contact details
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Configure Environment Variables
|
||||
|
||||
Add these to your `.env.local` or production environment:
|
||||
|
||||
```bash
|
||||
RADICALE_URL=http://localhost:5232
|
||||
RADICALE_USERNAME=your_username
|
||||
RADICALE_PASSWORD=your_password
|
||||
```
|
||||
|
||||
**Note:** If these variables are not set, sync will be disabled and the app will work normally.
|
||||
|
||||
### 2. Radicale Server Setup
|
||||
|
||||
Make sure your Radicale server:
|
||||
- Is accessible from your application server
|
||||
- Has a user created with the credentials you configured
|
||||
- Has a contacts collection at: `{username}/contacts/`
|
||||
|
||||
### 3. One-Time Initial Sync
|
||||
|
||||
To sync all existing contacts to Radicale:
|
||||
|
||||
```bash
|
||||
node export-contacts-to-radicale.mjs
|
||||
```
|
||||
|
||||
This script will:
|
||||
- Prompt for Radicale URL, username, and password
|
||||
- Export all active contacts as VCARDs
|
||||
- Upload them to your Radicale server
|
||||
|
||||
## How It Works
|
||||
|
||||
### When Creating a Contact
|
||||
|
||||
```javascript
|
||||
// POST /api/contacts
|
||||
const contact = createContact(data);
|
||||
|
||||
// Sync to Radicale asynchronously (non-blocking)
|
||||
syncContactAsync(contact);
|
||||
|
||||
return NextResponse.json(contact);
|
||||
```
|
||||
|
||||
### When Updating a Contact
|
||||
|
||||
```javascript
|
||||
// PUT /api/contacts/[id]
|
||||
const contact = updateContact(contactId, data);
|
||||
|
||||
// Sync updated contact to Radicale
|
||||
syncContactAsync(contact);
|
||||
|
||||
return NextResponse.json(contact);
|
||||
```
|
||||
|
||||
### When Deleting a Contact
|
||||
|
||||
```javascript
|
||||
// DELETE /api/contacts/[id]
|
||||
deleteContact(contactId);
|
||||
|
||||
// Delete from Radicale asynchronously
|
||||
deleteContactAsync(contactId);
|
||||
|
||||
return NextResponse.json({ message: "Contact deleted" });
|
||||
```
|
||||
|
||||
## VCARD Format
|
||||
|
||||
Each contact is exported with the following fields:
|
||||
|
||||
- **UID**: `contact-{id}@panel-app`
|
||||
- **FN/N**: Full name and structured name
|
||||
- **ORG**: Company
|
||||
- **TITLE**: Position/Title
|
||||
- **TEL**: Phone numbers (multiple supported - first as WORK, others as CELL)
|
||||
- **EMAIL**: Email address
|
||||
- **NOTE**: Contact type + notes
|
||||
- **CATEGORIES**: Based on contact type (Projekty, Wykonawcy, Urzędy, etc.)
|
||||
- **REV**: Last modified timestamp
|
||||
|
||||
## VCARD Storage Path
|
||||
|
||||
VCARDs are stored at:
|
||||
```
|
||||
{RADICALE_URL}/{RADICALE_USERNAME}/contacts/contact-{id}.vcf
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
http://localhost:5232/admin/contacts/contact-123.vcf
|
||||
```
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Problem: Sync not working / contacts not appearing in Radicale**
|
||||
|
||||
**Check 1: Environment Variables**
|
||||
```bash
|
||||
# Verify variables are set
|
||||
echo $RADICALE_URL
|
||||
echo $RADICALE_USERNAME
|
||||
# Don't echo password for security
|
||||
```
|
||||
|
||||
**Check 2: Radicale Server Connectivity**
|
||||
```bash
|
||||
# Test server is reachable
|
||||
curl -I http://your-radicale-server:5232
|
||||
|
||||
# Test authentication
|
||||
curl -u username:password http://your-radicale-server:5232/username/contacts/
|
||||
```
|
||||
|
||||
**Check 3: Application Logs**
|
||||
Look for sync messages in your application console:
|
||||
```
|
||||
✅ Synced contact 123 to Radicale
|
||||
❌ Failed to sync contact 456 to Radicale: 401 - Unauthorized
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Problem: 401 Unauthorized errors**
|
||||
- **Cause**: Invalid credentials or user doesn't exist
|
||||
- **Solution**:
|
||||
- Verify `RADICALE_USERNAME` and `RADICALE_PASSWORD`
|
||||
- Ensure user exists in Radicale
|
||||
- Check Radicale authentication method (basic auth vs htpasswd)
|
||||
|
||||
---
|
||||
|
||||
**Problem: 404 Not Found errors**
|
||||
- **Cause**: Contacts collection doesn't exist
|
||||
- **Solution**:
|
||||
- Create collection in Radicale: `/{username}/contacts/`
|
||||
- Verify collection URL matches `RADICALE_URL`
|
||||
- Check Radicale collection rights and permissions
|
||||
|
||||
---
|
||||
|
||||
**Problem: Network timeout or connection refused**
|
||||
- **Cause**: Radicale server not accessible from app server
|
||||
- **Solution**:
|
||||
- Check firewall rules
|
||||
- Verify Radicale is running: `systemctl status radicale`
|
||||
- Test with curl from app server
|
||||
- If using Docker, ensure network connectivity
|
||||
|
||||
---
|
||||
|
||||
**Problem: Contacts created but not syncing**
|
||||
- **Cause**: Environment variables not loaded or sync disabled
|
||||
- **Solution**:
|
||||
- Restart application after setting env vars
|
||||
- Check `.env` or `.env.local` file exists
|
||||
- Verify Next.js loaded environment: check server startup logs
|
||||
- Test with manual export script: `node export-contacts-to-radicale.mjs`
|
||||
|
||||
---
|
||||
|
||||
**Problem: Duplicate contacts in Radicale**
|
||||
- **Cause**: Re-running export script or UID conflicts
|
||||
- **Solution**:
|
||||
- UIDs are unique: `contact-{id}@panel-app`
|
||||
- Existing contacts are overwritten on update
|
||||
- Delete duplicates manually in Radicale if needed
|
||||
|
||||
---
|
||||
|
||||
**Problem: VCARD format errors in Radicale**
|
||||
- **Cause**: Invalid characters or incomplete data
|
||||
- **Solution**:
|
||||
- Check contact has at least name field
|
||||
- Special characters in names are escaped
|
||||
- Phone/email fields are optional
|
||||
- Review contact data for completeness
|
||||
|
||||
---
|
||||
|
||||
### Monitoring Sync Status
|
||||
|
||||
**Enable Detailed Logging**
|
||||
Edit `src/lib/radicale-sync.js` to increase logging verbosity:
|
||||
```javascript
|
||||
// Add more console.log statements
|
||||
console.log('Syncing contact:', contact);
|
||||
console.log('VCARD:', vcard);
|
||||
console.log('Response:', await response.text());
|
||||
```
|
||||
|
||||
**Check Radicale Server Logs**
|
||||
```bash
|
||||
# Typical log location
|
||||
tail -f /var/log/radicale/radicale.log
|
||||
|
||||
# Or check systemd journal
|
||||
journalctl -u radicale -f
|
||||
```
|
||||
|
||||
**Manual Sync Test**
|
||||
Test individual contact sync:
|
||||
```bash
|
||||
# Use the export script for a single contact
|
||||
node export-contacts-to-radicale.mjs
|
||||
# Select specific contact when prompted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Disable Sync Temporarily
|
||||
|
||||
Comment out environment variables to disable sync without removing configuration:
|
||||
```bash
|
||||
# .env.local
|
||||
# RADICALE_URL=http://localhost:5232
|
||||
# RADICALE_USERNAME=admin
|
||||
# RADICALE_PASSWORD=secret
|
||||
```
|
||||
|
||||
Application will function normally without sync enabled.
|
||||
|
||||
---
|
||||
|
||||
### Manual Sync Endpoint
|
||||
|
||||
For manual sync control, you can trigger sync via API:
|
||||
|
||||
```bash
|
||||
# Sync specific contact
|
||||
POST /api/contacts/{id}/sync
|
||||
|
||||
# Response
|
||||
{
|
||||
"success": true,
|
||||
"message": "Contact synced to Radicale"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error Codes Reference
|
||||
|
||||
| Code | Meaning | Solution |
|
||||
|------|---------|----------|
|
||||
| 401 | Unauthorized | Check credentials |
|
||||
| 403 | Forbidden | Verify user has write permissions |
|
||||
| 404 | Not Found | Create contacts collection |
|
||||
| 409 | Conflict | UID collision (rare) |
|
||||
| 500 | Server Error | Check Radicale server logs |
|
||||
| ECONNREFUSED | Connection Refused | Server not reachable |
|
||||
| ETIMEDOUT | Timeout | Network/firewall issue |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Configuration Reference
|
||||
|
||||
### Required Environment Variables
|
||||
```bash
|
||||
RADICALE_URL=http://localhost:5232
|
||||
RADICALE_USERNAME=your_username
|
||||
RADICALE_PASSWORD=your_password
|
||||
```
|
||||
|
||||
### Default Settings
|
||||
- **Collection Path**: `/{username}/contacts/`
|
||||
- **VCARD Version**: 3.0
|
||||
- **UID Format**: `contact-{id}@panel-app`
|
||||
- **Sync Mode**: Asynchronous (non-blocking)
|
||||
- **Retry Logic**: None (fire-and-forget)
|
||||
|
||||
---
|
||||
|
||||
## 📂 Files Reference
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lib/radicale-sync.js` | Core sync logic, VCARD generation |
|
||||
| `src/app/api/contacts/route.js` | Create sync trigger |
|
||||
| `src/app/api/contacts/[id]/route.js` | Update/delete sync triggers |
|
||||
| `export-contacts-to-radicale.mjs` | Bulk export utility |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security Best Practices
|
||||
|
||||
✅ **Do's:**
|
||||
- Use HTTPS for production Radicale servers
|
||||
- Store credentials in environment variables (never in code)
|
||||
- Use strong, unique passwords
|
||||
- Limit Radicale user permissions to contacts collection only
|
||||
- Regularly rotate credentials
|
||||
- Use separate credentials per environment (dev/staging/prod)
|
||||
|
||||
❌ **Don'ts:**
|
||||
- Don't commit credentials to git
|
||||
- Don't use HTTP in production
|
||||
- Don't share credentials between environments
|
||||
- Don't log passwords or sensitive data
|
||||
- Don't grant unnecessary permissions
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Advanced Configuration
|
||||
|
||||
### Custom Collection Path
|
||||
Modify `src/lib/radicale-sync.js`:
|
||||
```javascript
|
||||
const baseUrl = `${process.env.RADICALE_URL}/${process.env.RADICALE_USERNAME}/my-custom-collection/`;
|
||||
```
|
||||
|
||||
### Batch Sync Operations
|
||||
For large-scale sync (future enhancement):
|
||||
```javascript
|
||||
// Collect contacts, then sync in batches
|
||||
const batchSize = 50;
|
||||
// Implement batch logic
|
||||
```
|
||||
|
||||
### Webhook Integration
|
||||
Future: Trigger webhooks on sync events:
|
||||
```javascript
|
||||
// POST to webhook URL on sync success/failure
|
||||
fetch(WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ event: 'contact_synced', contact_id: id })
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**See Also**: [Contacts System](CONTACTS_SYSTEM.md) | [Main README](../../README.md#-cardav-integration-radicale) | [API Documentation](../../README.md#contacts)
|
||||
518
docs/features/ROUTE_PLANNING.md
Normal file
518
docs/features/ROUTE_PLANNING.md
Normal file
@@ -0,0 +1,518 @@
|
||||
# Route Planning Feature with Optimization
|
||||
|
||||
This feature allows you to plan routes between multiple project locations using OpenRouteService API, with automatic optimization to find the fastest route regardless of point addition order.
|
||||
|
||||
## 🌟 Overview
|
||||
|
||||
The route planning system integrates with your project map to help optimize field visits. It supports:
|
||||
- Multi-point routing through project locations
|
||||
- Automatic route optimization for 3+ points
|
||||
- Visual route display on the map
|
||||
- Distance and time estimation
|
||||
- Hybrid optimization approach (API + permutation testing)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Setup
|
||||
|
||||
1. **Get an API Key**:
|
||||
- Visit [OpenRouteService](https://openrouteservice.org/)
|
||||
- Sign up for a free account
|
||||
- Generate an API key
|
||||
|
||||
2. **Configure Environment**:
|
||||
- Copy `.env.example` to `.env.local`
|
||||
- Add your API key: `NEXT_PUBLIC_ORS_API_KEY=your_actual_api_key`
|
||||
|
||||
3. **Install Dependencies**:
|
||||
```bash
|
||||
npm install @mapbox/polyline
|
||||
```
|
||||
|
||||
4. **Restart Development Server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## How to Use
|
||||
|
||||
### Basic Routing (2 Points)
|
||||
1. **Select Route Tool**: Click the route icon in the tool panel (looks like a path)
|
||||
2. **Add Projects**: Click on project markers to add them to your route
|
||||
3. **Calculate Route**: Click "Calculate Route" to get directions
|
||||
4. **View Results**: See distance, duration, and route path on the map
|
||||
|
||||
### Optimized Routing (3+ Points)
|
||||
1. **Select Route Tool**: Click the route icon in the tool panel
|
||||
2. **Add Projects**: Click on project markers (order doesn't matter)
|
||||
3. **Find Optimal Route**: Click "Find Optimal Route" - system automatically finds fastest path
|
||||
4. **View Optimization Results**: See which route order was selected and performance stats
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- **Multi-point routing**: Plan routes through multiple project locations
|
||||
- **Visual route display**: Blue dashed line shows the calculated route
|
||||
- **Route markers**: Green start marker, red end marker
|
||||
- **Route information**: Distance and estimated travel time
|
||||
- **Interactive management**: Add/remove projects from route
|
||||
- **Map auto-fit**: Automatically adjusts map view to show entire route
|
||||
|
||||
### Optimization Features ✨
|
||||
- **Hybrid Optimization**: Uses ORS Optimization API first, falls back to permutation testing
|
||||
- **Smart Fallback**: Automatically switches to proven permutation method if ORS fails
|
||||
- **Order Detection**: Clearly shows when route order was actually optimized vs unchanged
|
||||
- **Large Point Support**: Can handle up to 50+ points with ORS API
|
||||
- **Performance Monitoring**: Detailed logging of optimization approach and results
|
||||
- **Real-time Progress**: Shows "Finding Optimal Route..." during calculation
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Core Functions
|
||||
|
||||
#### `calculateRoute()`
|
||||
Main function that handles both basic and optimized routing with hybrid approach:
|
||||
|
||||
```javascript
|
||||
const calculateRoute = async () => {
|
||||
// For 2 points: direct calculation
|
||||
if (coordinates.length === 2) {
|
||||
const routeData = await calculateRouteForCoordinates(coordinates);
|
||||
setRouteData({...routeData, optimized: false});
|
||||
return;
|
||||
}
|
||||
|
||||
// For 3+ points: try ORS Optimization API first
|
||||
let optimizationRequest = {
|
||||
jobs: coordinates.map((coord, index) => ({
|
||||
id: index,
|
||||
location: coord,
|
||||
service: 0
|
||||
})),
|
||||
vehicles: [{
|
||||
id: 0,
|
||||
profile: 'driving-car',
|
||||
// No fixed start/end for true optimization
|
||||
capacity: [coordinates.length]
|
||||
}],
|
||||
options: { g: true }
|
||||
};
|
||||
|
||||
try {
|
||||
const optimizationResponse = await fetch('https://api.openrouteservice.org/optimization', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(optimizationRequest)
|
||||
});
|
||||
const optimizationData = await optimizationResponse.json();
|
||||
|
||||
// Extract optimized order from ORS response
|
||||
const optimizedCoordinates = extractOptimizedOrder(optimizationData, coordinates);
|
||||
|
||||
// Check if order actually changed
|
||||
const orderChanged = detectOrderChange(coordinates, optimizedCoordinates);
|
||||
|
||||
if (orderChanged) {
|
||||
// Use optimized order
|
||||
const routeData = await calculateRouteForCoordinates(optimizedCoordinates);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'ORS_Optimization_API',
|
||||
totalJobs: coordinates.length,
|
||||
duration: optimizationData.routes[0].duration,
|
||||
distance: optimizationData.routes[0].distance
|
||||
}});
|
||||
} else {
|
||||
// Fallback to permutation testing
|
||||
console.log('ORS optimization did not change order, trying permutations...');
|
||||
const bestRoute = await findOptimalRouteByPermutations(coordinates);
|
||||
const routeData = await calculateRouteForCoordinates(bestRoute);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'Permutation_Testing',
|
||||
totalJobs: coordinates.length,
|
||||
duration: routeData.summary.total_duration,
|
||||
distance: routeData.summary.total_distance
|
||||
}});
|
||||
}
|
||||
} catch (error) {
|
||||
// Complete fallback to permutations
|
||||
console.log('ORS optimization failed, using permutation fallback...');
|
||||
const bestRoute = await findOptimalRouteByPermutations(coordinates);
|
||||
const routeData = await calculateRouteForCoordinates(bestRoute);
|
||||
setRouteData({...routeData, optimized: true, optimizationStats: {
|
||||
method: 'Permutation_Testing',
|
||||
totalJobs: coordinates.length,
|
||||
duration: routeData.summary.total_duration,
|
||||
distance: routeData.summary.total_distance
|
||||
}});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### `calculateRouteForCoordinates(coordinates)`
|
||||
Handles individual OpenRouteService Directions API calls:
|
||||
|
||||
```javascript
|
||||
const calculateRouteForCoordinates = async (coordinates) => {
|
||||
const requestBody = {
|
||||
coordinates: coordinates,
|
||||
format: 'geojson',
|
||||
instructions: true,
|
||||
geometry_simplify: false,
|
||||
continue_straight: false,
|
||||
roundabout_exits: true,
|
||||
attributes: ['avgspeed', 'percentage']
|
||||
};
|
||||
|
||||
const response = await fetch('https://api.openrouteservice.org/v2/directions/driving-car', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': process.env.NEXT_PUBLIC_ORS_API_KEY,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
```
|
||||
|
||||
### UI Components
|
||||
|
||||
#### Dynamic Button Text
|
||||
```javascript
|
||||
{routeProjects.length > 2 ? 'Find Optimal Route' : 'Calculate Route'}
|
||||
```
|
||||
|
||||
#### Optimization Status Display
|
||||
```javascript
|
||||
{routeData.optimized && (
|
||||
<div className="mb-2 p-2 bg-green-50 border border-green-200 rounded">
|
||||
<div className="flex items-center gap-1 font-medium">
|
||||
✅ Route Optimized
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
Tested {routeData.optimizationStats.totalPermutations} routes
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimization Limits
|
||||
- **Maximum Points**: Limited to 50 points (ORS can handle 100+ in some cases)
|
||||
- **Algorithm**: Advanced TSP solver instead of brute-force permutations
|
||||
- **API Calls**: Only 2 API calls (1 optimization + 1 detailed route)
|
||||
- **Processing Time**: ~1-2 seconds for 50 points (much faster than permutation testing)
|
||||
|
||||
### Memory Usage
|
||||
- Each route response contains detailed geometry data
|
||||
- Large numbers of points can consume significant memory
|
||||
- Automatic cleanup of unused route data
|
||||
|
||||
## API Integration
|
||||
|
||||
### OpenRouteService Optimization API
|
||||
```javascript
|
||||
{
|
||||
jobs: [
|
||||
{ id: 0, location: [lng, lat], service: 0 },
|
||||
{ id: 1, location: [lng, lat], service: 0 }
|
||||
],
|
||||
vehicles: [{
|
||||
id: 0,
|
||||
profile: 'driving-car',
|
||||
start: [lng, lat],
|
||||
end: [lng, lat],
|
||||
capacity: [point_count]
|
||||
}],
|
||||
options: { g: true }
|
||||
}
|
||||
```
|
||||
|
||||
### Directions API Parameters
|
||||
```javascript
|
||||
{
|
||||
coordinates: [[lng, lat], [lng, lat], ...],
|
||||
format: 'geojson',
|
||||
instructions: true,
|
||||
geometry_simplify: false,
|
||||
continue_straight: false,
|
||||
roundabout_exits: true,
|
||||
attributes: ['avgspeed', 'percentage']
|
||||
}
|
||||
```
|
||||
|
||||
### Response Handling
|
||||
- **Optimization API**: `data.routes[0].steps[]` for optimized order
|
||||
- **Directions API**: `data.routes[0].summary` for route details
|
||||
- **Fallback Path**: `data.features[0].properties.segments[0]`
|
||||
- **Geometry**: Supports both encoded polylines and direct coordinates
|
||||
- **Error Handling**: Graceful fallback for failed calculations
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Failed to calculate route"
|
||||
- **Cause**: Invalid API key or network issues
|
||||
- **Solution**: Verify `NEXT_PUBLIC_ORS_API_KEY` in `.env.local`
|
||||
|
||||
#### "Too many points for optimization"
|
||||
- **Cause**: Selected more than 50 points
|
||||
- **Solution**: Reduce to 50 or fewer points, or use manual routing
|
||||
|
||||
#### Optimization taking too long
|
||||
- **Cause**: Large number of points or slow API responses
|
||||
- **Solution**: Reduce points or wait for completion (much faster than before)
|
||||
|
||||
#### Optimization API unavailable
|
||||
- **Cause**: ORS Optimization API temporarily unavailable
|
||||
- **Solution**: Falls back to direct routing without optimization
|
||||
|
||||
#### Route order not optimized
|
||||
- **Cause**: ORS Optimization API returned same order or failed
|
||||
- **Solution**: System automatically falls back to permutation testing for guaranteed optimization
|
||||
|
||||
#### Optimization shows "Order unchanged"
|
||||
- **Cause**: Points may already be in optimal order, or API returned original sequence
|
||||
- **Solution**: Check browser console for detailed optimization logs
|
||||
|
||||
#### Permutation fallback activated
|
||||
- **Cause**: ORS API unavailable or returned suboptimal results
|
||||
- **Solution**: This is normal behavior - permutation testing ensures optimization
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. "API Key Missing" Error
|
||||
**Symptom**: Route calculation fails with authentication error
|
||||
|
||||
**Solutions**:
|
||||
- Check `.env.local` file has `NEXT_PUBLIC_ORS_API_KEY=your_key`
|
||||
- Verify no extra spaces around the key
|
||||
- Ensure development server was restarted after adding the key
|
||||
- Confirm your OpenRouteService API key is active
|
||||
|
||||
```bash
|
||||
# Verify environment variable
|
||||
echo $env:NEXT_PUBLIC_ORS_API_KEY
|
||||
# Should output your API key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. Route Not Displaying on Map
|
||||
**Symptom**: Calculation succeeds but no route visible
|
||||
|
||||
**Solutions**:
|
||||
- Check browser console for coordinate transformation errors
|
||||
- Verify all projects have valid coordinates in database
|
||||
- Confirm map is zoomed to appropriate level
|
||||
- Check if route layer is enabled in layer control
|
||||
|
||||
**Debug**:
|
||||
```javascript
|
||||
// Check route data in browser console
|
||||
console.log('Route GeoJSON:', routeData.geojson);
|
||||
console.log('Route bounds:', routeData.bounds);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. Optimization Takes Too Long
|
||||
**Symptom**: "Find Optimal Route" hangs or times out for many points
|
||||
|
||||
**Current Limits**:
|
||||
- 8+ points: May take 30+ seconds
|
||||
- 10+ points: Not recommended (factorial growth)
|
||||
|
||||
**Solutions**:
|
||||
- Split route into multiple segments
|
||||
- Use manual point selection for 8+ locations
|
||||
- Consider implementing A* or genetic algorithm for large routes
|
||||
|
||||
**Permutation Growth**:
|
||||
```
|
||||
3 points = 6 routes to test
|
||||
4 points = 24 routes
|
||||
5 points = 120 routes
|
||||
6 points = 720 routes
|
||||
7 points = 5,040 routes
|
||||
8 points = 40,320 routes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. API Rate Limit Exceeded
|
||||
**Symptom**: Error 429 or "Too many requests"
|
||||
|
||||
**Solutions**:
|
||||
- OpenRouteService free tier: 40 requests/minute, 500/day
|
||||
- Wait 1 minute and try again
|
||||
- Consider upgrading to paid plan for higher limits
|
||||
- Implement request queuing with delays
|
||||
|
||||
```javascript
|
||||
// Add rate limiting check
|
||||
if (routeProjects.length > 5) {
|
||||
alert('Large route may hit rate limits. Consider breaking into segments.');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. Incorrect Route Order
|
||||
**Symptom**: Optimization doesn't select expected fastest route
|
||||
|
||||
**Causes**:
|
||||
- Road network topology (one-way streets, traffic restrictions)
|
||||
- API routing preferences (avoid highways, ferries)
|
||||
- Distance vs time optimization trade-offs
|
||||
|
||||
**Verification**:
|
||||
```javascript
|
||||
// Check all tested routes in console
|
||||
routeData.optimizationStats.testedRoutes.forEach(route => {
|
||||
console.log(`Route ${route.order}: ${route.distance}m in ${route.duration}s`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. Map Coordinate Transformation Errors
|
||||
**Symptom**: "Failed to transform coordinates" in console
|
||||
|
||||
**Solutions**:
|
||||
- Verify Proj4 definitions are loaded
|
||||
- Check project coordinates are in valid EPSG:2180 format
|
||||
- Confirm transformation libraries are properly initialized
|
||||
|
||||
```javascript
|
||||
// Test coordinate transformation
|
||||
import proj4 from 'proj4';
|
||||
const wgs84 = proj4('EPSG:2180', 'EPSG:4326', [x, y]);
|
||||
console.log('Transformed:', wgs84);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. **Batch Route Calculations**: Group nearby projects before calculating routes
|
||||
2. **Cache Routes**: Store frequently used routes in localStorage
|
||||
3. **Limit Points**: Use max 7 points for real-time optimization
|
||||
4. **Debounce Updates**: Wait for user to finish selecting points
|
||||
5. **Progressive Loading**: Calculate partial routes while building full path
|
||||
|
||||
---
|
||||
|
||||
### API Limitations
|
||||
|
||||
| Tier | Requests/Minute | Requests/Day | Cost |
|
||||
|------|----------------|--------------|------|
|
||||
| Free | 40 | 500 | $0 |
|
||||
| Starter | 300 | 10,000 | Contact ORS |
|
||||
| Business | Custom | Custom | Contact ORS |
|
||||
|
||||
**Best Practices**:
|
||||
- Avoid unnecessary recalculations
|
||||
- Implement client-side caching
|
||||
- Show loading states during API calls
|
||||
- Handle errors gracefully with user feedback
|
||||
|
||||
---
|
||||
|
||||
### Quick Reference
|
||||
|
||||
**Enable Route Planning**:
|
||||
```bash
|
||||
# 1. Get API key from openrouteservice.org
|
||||
# 2. Add to .env.local
|
||||
NEXT_PUBLIC_ORS_API_KEY=your_key_here
|
||||
# 3. Restart dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Debug Mode**:
|
||||
```javascript
|
||||
// Enable in RoutePanel.js
|
||||
const DEBUG = true;
|
||||
// Logs all tested routes and optimization stats
|
||||
```
|
||||
|
||||
**Performance Monitoring**:
|
||||
```javascript
|
||||
console.time('Route Optimization');
|
||||
await optimizeRoute();
|
||||
console.timeEnd('Route Optimization');
|
||||
// Shows exact optimization duration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Debug Information
|
||||
Check browser console for detailed logs:
|
||||
- Coordinate parsing details
|
||||
- API request/response structures
|
||||
- **Optimization approach used** (ORS API vs permutation fallback)
|
||||
- **Order change detection** (whether optimization actually improved the route)
|
||||
- Performance timing information
|
||||
- **Original vs optimized coordinate sequences**
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Structure
|
||||
|
||||
```
|
||||
src/app/projects/map/page.js # Main map page with routing logic
|
||||
src/components/ui/LeafletMap.js # Map component with route rendering
|
||||
src/components/ui/mapLayers.js # Map layer configurations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
- `@mapbox/polyline`: For decoding route geometry
|
||||
- `leaflet`: Map rendering library
|
||||
- `react-leaflet`: React integration for Leaflet
|
||||
- `proj4`: Coordinate system transformations
|
||||
- OpenRouteService API key (free tier available)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
- **Advanced Vehicle Constraints**: Multiple vehicles, capacity limits, time windows
|
||||
- **Route Preferences**: Allow users to prioritize distance vs time vs fuel efficiency
|
||||
- **Real-time Traffic**: Integration with live traffic data
|
||||
- **Route History**: Save and compare previously optimized routes
|
||||
- **Mobile Optimization**: Optimize routes considering current location
|
||||
- **Multi-stop Services**: Add service times at each location
|
||||
- **Advanced Optimization**: Implement A* or genetic algorithms for 8+ points
|
||||
- **Multi-Day Routes**: Break long routes into segments with overnight stops
|
||||
- **Export Options**: Export routes to GPS devices or Google Maps
|
||||
- **Cost Estimation**: Calculate fuel costs and travel expenses
|
||||
|
||||
---
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- [OpenRouteService API Documentation](https://openrouteservice.org/dev/#/api-docs)
|
||||
- [Directions API Reference](https://openrouteservice.org/dev/#/api-docs/v2/directions)
|
||||
- [Polyline Encoding](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
|
||||
- [Leaflet Routing Integration](https://www.liedman.net/leaflet-routing-machine/)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2025
|
||||
**Maintainer**: Panel Development Team
|
||||
272
export-contacts-to-radicale.mjs
Normal file
272
export-contacts-to-radicale.mjs
Normal file
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* One-time script to export all contacts as VCARDs and upload to Radicale
|
||||
* Usage: node export-contacts-to-radicale.mjs
|
||||
*/
|
||||
|
||||
import db from './src/lib/db.js';
|
||||
import readline from 'readline';
|
||||
import { createInterface } from 'readline';
|
||||
|
||||
// VCARD generation helper
|
||||
function generateVCard(contact) {
|
||||
const lines = ['BEGIN:VCARD', 'VERSION:3.0'];
|
||||
|
||||
// UID - unique identifier
|
||||
lines.push(`UID:contact-${contact.contact_id}@panel-app`);
|
||||
|
||||
// Name (FN = Formatted Name, N = Structured Name)
|
||||
if (contact.name) {
|
||||
lines.push(`FN:${escapeVCardValue(contact.name)}`);
|
||||
|
||||
// Try to split name into components (Last;First;Middle;Prefix;Suffix)
|
||||
const nameParts = contact.name.trim().split(/\s+/);
|
||||
if (nameParts.length === 1) {
|
||||
lines.push(`N:${escapeVCardValue(nameParts[0])};;;;`);
|
||||
} else if (nameParts.length === 2) {
|
||||
lines.push(`N:${escapeVCardValue(nameParts[1])};${escapeVCardValue(nameParts[0])};;;`);
|
||||
} else {
|
||||
// More than 2 parts - first is first name, rest is last name
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`);
|
||||
}
|
||||
}
|
||||
|
||||
// Organization
|
||||
if (contact.company) {
|
||||
lines.push(`ORG:${escapeVCardValue(contact.company)}`);
|
||||
}
|
||||
|
||||
// Title/Position
|
||||
if (contact.position) {
|
||||
lines.push(`TITLE:${escapeVCardValue(contact.position)}`);
|
||||
}
|
||||
|
||||
// Phone numbers - handle multiple phones
|
||||
if (contact.phone) {
|
||||
let phones = [];
|
||||
try {
|
||||
// Try to parse as JSON array
|
||||
const parsed = JSON.parse(contact.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [contact.phone];
|
||||
} catch {
|
||||
// Fall back to comma-separated or single value
|
||||
phones = contact.phone.includes(',')
|
||||
? contact.phone.split(',').map(p => p.trim()).filter(p => p)
|
||||
: [contact.phone];
|
||||
}
|
||||
|
||||
phones.forEach((phone, index) => {
|
||||
if (phone) {
|
||||
// First phone is WORK, others are CELL
|
||||
const type = index === 0 ? 'WORK' : 'CELL';
|
||||
lines.push(`TEL;TYPE=${type},VOICE:${escapeVCardValue(phone)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Email
|
||||
if (contact.email) {
|
||||
lines.push(`EMAIL;TYPE=INTERNET,WORK:${escapeVCardValue(contact.email)}`);
|
||||
}
|
||||
|
||||
// Notes - combine contact type, position context, and notes
|
||||
const noteParts = [];
|
||||
if (contact.contact_type) {
|
||||
const typeLabels = {
|
||||
project: 'Kontakt projektowy',
|
||||
contractor: 'Wykonawca',
|
||||
office: 'Urząd',
|
||||
supplier: 'Dostawca',
|
||||
other: 'Inny'
|
||||
};
|
||||
noteParts.push(`Typ: ${typeLabels[contact.contact_type] || contact.contact_type}`);
|
||||
}
|
||||
if (contact.notes) {
|
||||
noteParts.push(contact.notes);
|
||||
}
|
||||
if (noteParts.length > 0) {
|
||||
lines.push(`NOTE:${escapeVCardValue(noteParts.join('\\n'))}`);
|
||||
}
|
||||
|
||||
// Categories based on contact type
|
||||
if (contact.contact_type) {
|
||||
const categories = {
|
||||
project: 'Projekty',
|
||||
contractor: 'Wykonawcy',
|
||||
office: 'Urzędy',
|
||||
supplier: 'Dostawcy',
|
||||
other: 'Inne'
|
||||
};
|
||||
lines.push(`CATEGORIES:${categories[contact.contact_type] || 'Inne'}`);
|
||||
}
|
||||
|
||||
// Timestamps
|
||||
if (contact.created_at) {
|
||||
const created = new Date(contact.created_at).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
||||
lines.push(`REV:${created}`);
|
||||
}
|
||||
|
||||
lines.push('END:VCARD');
|
||||
|
||||
return lines.join('\r\n') + '\r\n';
|
||||
}
|
||||
|
||||
// Escape special characters in VCARD values
|
||||
function escapeVCardValue(value) {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '');
|
||||
}
|
||||
|
||||
// Prompt for input
|
||||
function prompt(question) {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Upload VCARD to Radicale via CardDAV
|
||||
async function uploadToRadicale(vcard, contactId, radicaleUrl, username, password, forceUpdate = false) {
|
||||
const auth = Buffer.from(`${username}:${password}`).toString('base64');
|
||||
|
||||
// Ensure URL ends with /
|
||||
const baseUrl = radicaleUrl.endsWith('/') ? radicaleUrl : radicaleUrl + '/';
|
||||
|
||||
// Construct the URL for this specific contact
|
||||
// Format: {base_url}{username}/{addressbook_name}/{contact_id}.vcf
|
||||
const vcardUrl = `${baseUrl}${username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`;
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'text/vcard; charset=utf-8'
|
||||
};
|
||||
|
||||
// If not forcing update, only create if doesn't exist
|
||||
if (!forceUpdate) {
|
||||
headers['If-None-Match'] = '*';
|
||||
}
|
||||
|
||||
const response = await fetch(vcardUrl, {
|
||||
method: 'PUT',
|
||||
headers: headers,
|
||||
body: vcard
|
||||
});
|
||||
|
||||
// Handle conflict - try again with force update
|
||||
if (response.status === 412 || response.status === 409) {
|
||||
// Conflict - contact already exists, update it instead
|
||||
return await uploadToRadicale(vcard, contactId, radicaleUrl, username, password, true);
|
||||
}
|
||||
|
||||
if (response.ok || response.status === 201 || response.status === 204) {
|
||||
return { success: true, status: response.status, updated: forceUpdate };
|
||||
} else {
|
||||
const text = await response.text();
|
||||
return { success: false, status: response.status, error: text };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
console.log('🚀 Export Contacts to Radicale (CardDAV)\n');
|
||||
console.log('This script will export all active contacts as VCARDs and upload them to your Radicale server.\n');
|
||||
|
||||
// Get Radicale connection details
|
||||
const radicaleUrl = await prompt('Radicale URL (e.g., http://localhost:5232): ');
|
||||
const username = await prompt('Username: ');
|
||||
const password = await prompt('Password: ');
|
||||
|
||||
if (!radicaleUrl || !username || !password) {
|
||||
console.error('❌ All fields are required!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\n📊 Fetching contacts from database...\n');
|
||||
|
||||
// Get all active contacts
|
||||
const contacts = db.prepare(`
|
||||
SELECT * FROM contacts
|
||||
WHERE is_active = 1
|
||||
ORDER BY name ASC
|
||||
`).all();
|
||||
|
||||
if (contacts.length === 0) {
|
||||
console.log('ℹ️ No active contacts found.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${contacts.length} active contacts\n`);
|
||||
console.log('📤 Uploading to Radicale...\n');
|
||||
|
||||
let uploaded = 0;
|
||||
let updated = 0;
|
||||
let failed = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const contact of contacts) {
|
||||
const vcard = generateVCard(contact);
|
||||
const result = await uploadToRadicale(vcard, contact.contact_id, radicaleUrl, username, password);
|
||||
|
||||
|
||||
if (result.success) {
|
||||
if (result.updated) {
|
||||
updated++;
|
||||
console.log(`🔄 ${contact.name} (${contact.contact_id}) - updated`);
|
||||
} else {
|
||||
uploaded++;
|
||||
console.log(`✅ ${contact.name} (${contact.contact_id}) - created`);
|
||||
}
|
||||
} else {
|
||||
failed++;
|
||||
const errorMsg = `❌ ${contact.name} (${contact.contact_id}): ${result.error || `HTTP ${result.status}`}`;
|
||||
console.log(errorMsg);
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
|
||||
// Small delay to avoid overwhelming the server
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 Upload Summary:');
|
||||
console.log(` ✅ Created: ${uploaded}`);
|
||||
console.log(` 🔄 Updated: ${updated}`);
|
||||
console.log(` ❌ Failed: ${failed}`);
|
||||
console.log(` 📋 Total: ${contacts.length}`);
|
||||
|
||||
if (errors.length > 0 && errors.length <= 10) {
|
||||
console.log('\n❌ Failed uploads:');
|
||||
errors.forEach(err => console.log(` ${err}`));
|
||||
}
|
||||
|
||||
if (uploaded > 0 || updated > 0) {
|
||||
console.log('\n✨ Success! Your contacts have been exported to Radicale.');
|
||||
console.log(` Access them at: ${radicaleUrl}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('❌ Error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
58
export-projects-to-excel.mjs
Normal file
58
export-projects-to-excel.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getAllProjects } from './src/lib/queries/projects.js';
|
||||
|
||||
function exportProjectsToExcel() {
|
||||
try {
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Group projects by status
|
||||
const groupedProjects = projects.reduce((acc, project) => {
|
||||
const status = project.project_status || 'unknown';
|
||||
if (!acc[status]) {
|
||||
acc[status] = [];
|
||||
}
|
||||
acc[status].push({
|
||||
'Nazwa projektu': project.project_name,
|
||||
'Adres': project.address || '',
|
||||
'Działka': project.plot || '',
|
||||
'WP': project.wp || '',
|
||||
'Data zakończenia': project.finish_date || ''
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Polish status translations for sheet names
|
||||
const statusTranslations = {
|
||||
'registered': 'Zarejestrowany',
|
||||
'in_progress_design': 'W realizacji (projektowanie)',
|
||||
'in_progress_construction': 'W realizacji (budowa)',
|
||||
'fulfilled': 'Zakończony',
|
||||
'cancelled': 'Wycofany',
|
||||
'unknown': 'Nieznany'
|
||||
};
|
||||
|
||||
// Create workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Create a sheet for each status
|
||||
Object.keys(groupedProjects).forEach(status => {
|
||||
const sheetName = statusTranslations[status] || status;
|
||||
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
});
|
||||
|
||||
// Write to file
|
||||
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
XLSX.writeFile(workbook, filename);
|
||||
|
||||
console.log(`Excel file created: ${filename}`);
|
||||
console.log(`Sheets created for statuses: ${Object.keys(groupedProjects).join(', ')}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting projects to Excel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the export
|
||||
exportProjectsToExcel();
|
||||
@@ -1,60 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
console.log("Adding user tracking columns to notes table...\n");
|
||||
|
||||
try {
|
||||
console.log("Adding created_by column...");
|
||||
db.exec(`ALTER TABLE notes ADD COLUMN created_by TEXT;`);
|
||||
console.log("✓ created_by column added");
|
||||
} catch (e) {
|
||||
console.log("created_by column already exists or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Adding is_system column...");
|
||||
db.exec(`ALTER TABLE notes ADD COLUMN is_system INTEGER DEFAULT 0;`);
|
||||
console.log("✓ is_system column added");
|
||||
} catch (e) {
|
||||
console.log("is_system column already exists or error:", e.message);
|
||||
}
|
||||
|
||||
console.log("\nVerifying columns were added...");
|
||||
const schema = db.prepare("PRAGMA table_info(notes)").all();
|
||||
const hasCreatedBy = schema.some((col) => col.name === "created_by");
|
||||
const hasIsSystem = schema.some((col) => col.name === "is_system");
|
||||
|
||||
console.log("created_by exists:", hasCreatedBy);
|
||||
console.log("is_system exists:", hasIsSystem);
|
||||
|
||||
if (hasCreatedBy && hasIsSystem) {
|
||||
console.log("\n✅ All columns are now present!");
|
||||
|
||||
// Test a manual insert
|
||||
console.log("\nTesting manual note insert...");
|
||||
try {
|
||||
const result = db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, note, created_by, is_system, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
)
|
||||
.run(1, "Test note with user tracking", "test-user-id", 0);
|
||||
|
||||
console.log("Insert successful, ID:", result.lastInsertRowid);
|
||||
|
||||
// Clean up
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(
|
||||
result.lastInsertRowid
|
||||
);
|
||||
console.log("Test record cleaned up");
|
||||
} catch (error) {
|
||||
console.error("Insert failed:", error.message);
|
||||
}
|
||||
} else {
|
||||
console.log("\n❌ Some columns are still missing");
|
||||
}
|
||||
|
||||
db.close();
|
||||
@@ -1,37 +0,0 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const db = new Database("./data/database.sqlite");
|
||||
|
||||
console.log("Adding missing columns to project_tasks table...\n");
|
||||
|
||||
try {
|
||||
console.log("Adding created_at column...");
|
||||
db.exec(`ALTER TABLE project_tasks ADD COLUMN created_at TEXT;`);
|
||||
console.log("✓ created_at column added");
|
||||
} catch (e) {
|
||||
console.log("created_at column already exists or error:", e.message);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Adding updated_at column...");
|
||||
db.exec(`ALTER TABLE project_tasks ADD COLUMN updated_at TEXT;`);
|
||||
console.log("✓ updated_at column added");
|
||||
} catch (e) {
|
||||
console.log("updated_at column already exists or error:", e.message);
|
||||
}
|
||||
|
||||
console.log("\nVerifying columns were added...");
|
||||
const schema = db.prepare("PRAGMA table_info(project_tasks)").all();
|
||||
const hasCreatedAt = schema.some((col) => col.name === "created_at");
|
||||
const hasUpdatedAt = schema.some((col) => col.name === "updated_at");
|
||||
|
||||
console.log("created_at exists:", hasCreatedAt);
|
||||
console.log("updated_at exists:", hasUpdatedAt);
|
||||
|
||||
if (hasCreatedAt && hasUpdatedAt) {
|
||||
console.log("\n✅ All columns are now present!");
|
||||
} else {
|
||||
console.log("\n❌ Some columns are still missing");
|
||||
}
|
||||
|
||||
db.close();
|
||||
File diff suppressed because it is too large
Load Diff
5
init-db-temp.mjs
Normal file
5
init-db-temp.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
import initializeDatabase from './src/lib/init-db.js';
|
||||
|
||||
console.log('Initializing database...');
|
||||
initializeDatabase();
|
||||
console.log('Database initialized successfully!');
|
||||
3127
package-lock.json
generated
3127
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "panel",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,10 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"create-admin": "node scripts/create-admin.js",
|
||||
"export-projects": "node export-projects-to-excel.mjs",
|
||||
"send-due-date-reminders": "node send-due-date-reminders.mjs",
|
||||
"test-due-date-reminders": "node test-due-date-reminders.mjs",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
@@ -15,19 +19,27 @@
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mapbox/polyline": "^1.2.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"docxtemplater": "^3.67.6",
|
||||
"exceljs": "^4.4.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "15.1.8",
|
||||
"next": "15.1.11",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"node-fetch": "^3.3.2",
|
||||
"pizzip": "^3.2.0",
|
||||
"proj4": "^2.19.3",
|
||||
"proj4leaflet": "^1.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"recharts": "^2.15.3",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38,6 +50,7 @@
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/leaflet": "^1.9.18",
|
||||
"concurrently": "^9.2.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.8",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -10,13 +10,13 @@ async function createInitialAdmin() {
|
||||
|
||||
const adminUser = await createUser({
|
||||
name: "Administrator",
|
||||
email: "admin@localhost.com",
|
||||
username: "admin",
|
||||
password: "admin123456", // Change this in production!
|
||||
role: "admin"
|
||||
})
|
||||
|
||||
console.log("✅ Initial admin user created successfully!")
|
||||
console.log("📧 Email: admin@localhost.com")
|
||||
console.log("<EFBFBD> Username: admin")
|
||||
console.log("🔑 Password: admin123456")
|
||||
console.log("⚠️ Please change the password after first login!")
|
||||
console.log("👤 User ID:", adminUser.id)
|
||||
|
||||
206
scripts/create-sample-projects.js
Normal file
206
scripts/create-sample-projects.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import db from '../src/lib/db.js';
|
||||
import initializeDatabase from '../src/lib/init-db.js';
|
||||
|
||||
// Initialize the database
|
||||
initializeDatabase();
|
||||
|
||||
// Sample projects data
|
||||
const sampleProjects = [
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Residential Complex Alpha',
|
||||
address: 'ul. Główna 123',
|
||||
plot: 'Plot 45/6',
|
||||
district: 'Śródmieście',
|
||||
unit: 'Unit A',
|
||||
city: 'Warszawa',
|
||||
investment_number: 'INV-2025-001',
|
||||
finish_date: '2025-06-30',
|
||||
wp: 'WP-001',
|
||||
contact: 'Jan Kowalski, tel. 123-456-789',
|
||||
notes: 'Modern residential building with 50 apartments',
|
||||
coordinates: '52.2297,21.0122',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'registered'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Office Building Beta',
|
||||
address: 'al. Jerozolimskie 50',
|
||||
plot: 'Plot 12/8',
|
||||
district: 'Mokotów',
|
||||
unit: 'Unit B',
|
||||
city: 'Warszawa',
|
||||
investment_number: 'INV-2025-002',
|
||||
finish_date: '2025-09-15',
|
||||
wp: 'WP-002',
|
||||
contact: 'Anna Nowak, tel. 987-654-321',
|
||||
notes: 'Commercial office space, 10 floors',
|
||||
coordinates: '52.2215,21.0071',
|
||||
project_type: 'construction',
|
||||
project_status: 'in_progress_design'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Shopping Mall Gamma',
|
||||
address: 'pl. Centralny 1',
|
||||
plot: 'Plot 78/3',
|
||||
district: 'Centrum',
|
||||
unit: 'Unit C',
|
||||
city: 'Kraków',
|
||||
investment_number: 'INV-2025-003',
|
||||
finish_date: '2025-12-20',
|
||||
wp: 'WP-003',
|
||||
contact: 'Piotr Wiśniewski, tel. 555-123-456',
|
||||
notes: 'Large shopping center with parking',
|
||||
coordinates: '50.0647,19.9450',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'in_progress_construction'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Industrial Warehouse Delta',
|
||||
address: 'ul. Przemysłowa 100',
|
||||
plot: 'Plot 200/15',
|
||||
district: 'Przemysłowa',
|
||||
unit: 'Unit D',
|
||||
city: 'Łódź',
|
||||
investment_number: 'INV-2025-004',
|
||||
finish_date: '2025-08-10',
|
||||
wp: 'WP-004',
|
||||
contact: 'Maria Lewandowska, tel. 444-789-012',
|
||||
notes: 'Logistics warehouse facility',
|
||||
coordinates: '51.7592,19.4600',
|
||||
project_type: 'design',
|
||||
project_status: 'fulfilled'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Hotel Complex Epsilon',
|
||||
address: 'ul. Morska 25',
|
||||
plot: 'Plot 5/2',
|
||||
district: 'Nadmorze',
|
||||
unit: 'Unit E',
|
||||
city: 'Gdańsk',
|
||||
investment_number: 'INV-2025-005',
|
||||
finish_date: '2025-11-05',
|
||||
wp: 'WP-005',
|
||||
contact: 'Tomasz Malinowski, tel. 333-456-789',
|
||||
notes: 'Luxury hotel with conference facilities',
|
||||
coordinates: '54.3520,18.6466',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'registered'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'School Complex Zeta',
|
||||
address: 'ul. Edukacyjna 15',
|
||||
plot: 'Plot 30/4',
|
||||
district: 'Edukacyjny',
|
||||
unit: 'Unit F',
|
||||
city: 'Poznań',
|
||||
investment_number: 'INV-2025-006',
|
||||
finish_date: '2025-07-20',
|
||||
wp: 'WP-006',
|
||||
contact: 'Ewa Dombrowska, tel. 222-333-444',
|
||||
notes: 'Modern educational facility with sports complex',
|
||||
coordinates: '52.4064,16.9252',
|
||||
project_type: 'design',
|
||||
project_status: 'in_progress_design'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Medical Center Eta',
|
||||
address: 'al. Zdrowia 8',
|
||||
plot: 'Plot 67/9',
|
||||
district: 'Medyczny',
|
||||
unit: 'Unit G',
|
||||
city: 'Wrocław',
|
||||
investment_number: 'INV-2025-007',
|
||||
finish_date: '2025-10-30',
|
||||
wp: 'WP-007',
|
||||
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
|
||||
notes: 'Specialized medical center with emergency department',
|
||||
coordinates: '51.1079,17.0385',
|
||||
project_type: 'construction',
|
||||
project_status: 'in_progress_construction'
|
||||
},
|
||||
{
|
||||
contract_id: 2,
|
||||
project_name: 'Sports Stadium Theta',
|
||||
address: 'ul. Sportowa 50',
|
||||
plot: 'Plot 150/20',
|
||||
district: 'Sportowy',
|
||||
unit: 'Unit H',
|
||||
city: 'Szczecin',
|
||||
investment_number: 'INV-2025-008',
|
||||
finish_date: '2025-05-15',
|
||||
wp: 'WP-008',
|
||||
contact: 'Katarzyna Wojcik, tel. 999-888-777',
|
||||
notes: 'Multi-purpose sports stadium with seating for 20,000',
|
||||
coordinates: '53.4289,14.5530',
|
||||
project_type: 'design+construction',
|
||||
project_status: 'fulfilled'
|
||||
},
|
||||
{
|
||||
contract_id: 1,
|
||||
project_name: 'Library Complex Iota',
|
||||
address: 'pl. Wiedzy 3',
|
||||
plot: 'Plot 25/7',
|
||||
district: 'Kulturalny',
|
||||
unit: 'Unit I',
|
||||
city: 'Lublin',
|
||||
investment_number: 'INV-2025-009',
|
||||
finish_date: '2025-08-25',
|
||||
wp: 'WP-009',
|
||||
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
|
||||
notes: 'Modern library with digital archives and community spaces',
|
||||
coordinates: '51.2465,22.5684',
|
||||
project_type: 'design',
|
||||
project_status: 'registered'
|
||||
}
|
||||
];
|
||||
|
||||
console.log('Creating sample test projects...\n');
|
||||
|
||||
sampleProjects.forEach((projectData, index) => {
|
||||
try {
|
||||
// Generate project number based on contract
|
||||
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(projectData.contract_id);
|
||||
const existingProjects = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(projectData.contract_id);
|
||||
const sequentialNumber = existingProjects.count + 1;
|
||||
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO projects (
|
||||
contract_id, project_name, project_number, address, plot, district, unit, city,
|
||||
investment_number, finish_date, wp, contact, notes, coordinates,
|
||||
project_type, project_status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`).run(
|
||||
projectData.contract_id,
|
||||
projectData.project_name,
|
||||
projectNumber,
|
||||
projectData.address,
|
||||
projectData.plot,
|
||||
projectData.district,
|
||||
projectData.unit,
|
||||
projectData.city,
|
||||
projectData.investment_number,
|
||||
projectData.finish_date,
|
||||
projectData.wp,
|
||||
projectData.contact,
|
||||
projectData.notes,
|
||||
projectData.coordinates,
|
||||
projectData.project_type,
|
||||
projectData.project_status
|
||||
);
|
||||
|
||||
console.log(`✓ Created project: ${projectData.project_name} (ID: ${result.lastInsertRowid}, Number: ${projectNumber})`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`✗ Error creating project ${projectData.project_name}:`, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\nSample test projects created successfully!');
|
||||
107
send-due-date-reminders.mjs
Normal file
107
send-due-date-reminders.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Daily script to send due date reminders for projects
|
||||
* Runs nightly to check for projects due in 3 days and 1 day
|
||||
*/
|
||||
|
||||
import db from "./src/lib/db.js";
|
||||
import { createNotification, NOTIFICATION_TYPES } from "./src/lib/notifications.js";
|
||||
import { addDays, isBefore, parseISO, startOfDay } from "date-fns";
|
||||
|
||||
async function sendDueDateReminders() {
|
||||
try {
|
||||
console.log("🔍 Checking for projects with upcoming due dates...");
|
||||
|
||||
const today = startOfDay(new Date());
|
||||
const threeDaysFromNow = addDays(today, 3);
|
||||
const oneDayFromNow = addDays(today, 1);
|
||||
|
||||
// Get projects that are not fulfilled and have finish dates
|
||||
const projects = db.prepare(`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.finish_date,
|
||||
p.address,
|
||||
p.project_status,
|
||||
c.customer
|
||||
FROM projects p
|
||||
LEFT JOIN contracts c ON p.contract_id = c.contract_id
|
||||
WHERE
|
||||
p.finish_date IS NOT NULL
|
||||
AND p.project_status != 'fulfilled'
|
||||
AND p.project_status != 'cancelled'
|
||||
`).all();
|
||||
|
||||
console.log(`📋 Found ${projects.length} active projects with due dates`);
|
||||
|
||||
let remindersSent = 0;
|
||||
|
||||
for (const project of projects) {
|
||||
try {
|
||||
const finishDate = parseISO(project.finish_date);
|
||||
const finishDateStart = startOfDay(finishDate);
|
||||
|
||||
// Check if due in 3 days
|
||||
if (finishDateStart.getTime() === threeDaysFromNow.getTime()) {
|
||||
await sendReminder(project, 3);
|
||||
remindersSent++;
|
||||
}
|
||||
// Check if due in 1 day
|
||||
else if (finishDateStart.getTime() === oneDayFromNow.getTime()) {
|
||||
await sendReminder(project, 1);
|
||||
remindersSent++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing project ${project.project_id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Sent ${remindersSent} due date reminders`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("❌ Error in due date reminder script:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReminder(project, daysUntilDue) {
|
||||
try {
|
||||
// Get users who should receive notifications (admins and team leads)
|
||||
const recipients = db.prepare(`
|
||||
SELECT id, name, role
|
||||
FROM users
|
||||
WHERE role IN ('admin', 'team_lead') AND is_active = 1
|
||||
`).all();
|
||||
|
||||
if (recipients.length === 0) {
|
||||
console.log("⚠️ No active admin or team lead users found to notify");
|
||||
return;
|
||||
}
|
||||
|
||||
const dayText = daysUntilDue === 1 ? "dzień" : "dni";
|
||||
const title = `Projekt kończy się za ${daysUntilDue} ${dayText}`;
|
||||
const message = `Projekt "${project.project_name}" (${project.customer || 'Brak klienta'}) kończy się ${new Date(project.finish_date).toLocaleDateString('pl-PL')}. Adres: ${project.address || 'Brak adresu'}.`;
|
||||
|
||||
for (const user of recipients) {
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
type: NOTIFICATION_TYPES.DUE_DATE_REMINDER,
|
||||
title,
|
||||
message,
|
||||
resourceType: "project",
|
||||
resourceId: project.project_id.toString(),
|
||||
actionUrl: `/projects/${project.project_id}`,
|
||||
priority: daysUntilDue === 1 ? "urgent" : "high"
|
||||
});
|
||||
|
||||
console.log(`📢 Reminder sent to ${user.name} (${user.role}) for project: ${project.project_name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to send reminder for project ${project.project_id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the script
|
||||
sendDueDateReminders();
|
||||
52
setup-cron.sh
Normal file
52
setup-cron.sh
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Manual script to setup/restart cron jobs
|
||||
# Use this if cron wasn't started properly by the docker entrypoint
|
||||
|
||||
echo "🔧 Setting up cron jobs..."
|
||||
|
||||
# Ensure cron service is running
|
||||
if command -v service &> /dev/null; then
|
||||
service cron start 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Set up daily backup cron job (runs at 2 AM daily)
|
||||
echo "⏰ Setting up daily backup cron job (2 AM)..."
|
||||
echo "0 2 * * * cd /app && /usr/local/bin/node backup-db.mjs >> /app/data/backup.log 2>&1" > /etc/cron.d/backup-cron
|
||||
chmod 0644 /etc/cron.d/backup-cron
|
||||
|
||||
# Set up daily due date reminders cron job (runs at 3 AM daily)
|
||||
echo "⏰ Setting up daily due date reminders cron job (3 AM)..."
|
||||
echo "0 3 * * * cd /app && /usr/local/bin/node send-due-date-reminders.mjs >> /app/data/reminders.log 2>&1" > /etc/cron.d/reminders-cron
|
||||
chmod 0644 /etc/cron.d/reminders-cron
|
||||
|
||||
# Combine both cron jobs into crontab
|
||||
cat /etc/cron.d/backup-cron /etc/cron.d/reminders-cron > /tmp/combined-cron.tmp
|
||||
crontab /tmp/combined-cron.tmp
|
||||
rm /tmp/combined-cron.tmp
|
||||
|
||||
# Verify cron jobs are installed
|
||||
echo ""
|
||||
echo "✅ Cron jobs installed:"
|
||||
crontab -l
|
||||
|
||||
# Check if cron daemon is running
|
||||
echo ""
|
||||
if pgrep -x "cron" > /dev/null || pgrep -x "crond" > /dev/null; then
|
||||
echo "✅ Cron daemon is running"
|
||||
else
|
||||
echo "⚠️ Cron daemon is NOT running. Starting it..."
|
||||
if command -v cron &> /dev/null; then
|
||||
cron
|
||||
echo "✅ Cron daemon started"
|
||||
elif command -v crond &> /dev/null; then
|
||||
crond
|
||||
echo "✅ Cron daemon started"
|
||||
else
|
||||
echo "❌ Could not start cron daemon"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Cron setup complete!"
|
||||
136
src/app/admin/page.js
Normal file
136
src/app/admin/page.js
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session || session.user.role !== "admin") {
|
||||
router.push("/");
|
||||
}
|
||||
}, [session, status, router]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || session.user.role !== "admin") {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You need admin privileges to access this page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminPages = [
|
||||
{
|
||||
title: "Users",
|
||||
description: "Manage user accounts, roles, and permissions",
|
||||
href: "/admin/users",
|
||||
icon: "👥",
|
||||
color: "bg-blue-500 hover:bg-blue-600",
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
description: "Configure system settings, backups, and cron jobs",
|
||||
href: "/admin/settings",
|
||||
icon: "⚙️",
|
||||
color: "bg-gray-600 hover:bg-gray-700",
|
||||
},
|
||||
{
|
||||
title: "Audit Logs",
|
||||
description: "View system activity and audit trail",
|
||||
href: "/admin/audit-logs",
|
||||
icon: "📋",
|
||||
color: "bg-purple-500 hover:bg-purple-600",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Admin Panel
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage your application settings and users
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-blue-600 hover:text-blue-800 flex items-center gap-1"
|
||||
>
|
||||
← Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{adminPages.map((page) => (
|
||||
<Link
|
||||
key={page.href}
|
||||
href={page.href}
|
||||
className={`${page.color} rounded-xl p-6 text-white shadow-lg transform transition-all duration-200 hover:scale-105 hover:shadow-xl`}
|
||||
>
|
||||
<div className="text-4xl mb-4">{page.icon}</div>
|
||||
<h2 className="text-xl font-bold mb-2">{page.title}</h2>
|
||||
<p className="text-white/80 text-sm">{page.description}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Quick Info
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-gray-500">Logged in as</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{session.user.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-gray-500">Role</div>
|
||||
<div className="font-medium text-gray-900 capitalize">
|
||||
{session.user.role}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="text-gray-500">Environment</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{process.env.NODE_ENV || "development"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
335
src/app/admin/settings/page.js
Normal file
335
src/app/admin/settings/page.js
Normal file
@@ -0,0 +1,335 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
const [settings, setSettings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [cronStatus, setCronStatus] = useState(null);
|
||||
const [cronLoading, setCronLoading] = useState(false);
|
||||
const [cronActionLoading, setCronActionLoading] = useState(null);
|
||||
|
||||
// Redirect if not admin
|
||||
useEffect(() => {
|
||||
if (status === "loading") return;
|
||||
if (!session || session.user.role !== "admin") {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
fetchSettings();
|
||||
fetchUsers();
|
||||
fetchCronStatus();
|
||||
}, [session, status, router]);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/users");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCronStatus = async () => {
|
||||
setCronLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/cron");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCronStatus(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching cron status:", error);
|
||||
} finally {
|
||||
setCronLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCronAction = async (action) => {
|
||||
setCronActionLoading(action);
|
||||
try {
|
||||
const response = await fetch("/api/admin/cron", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
alert(data.message);
|
||||
fetchCronStatus();
|
||||
} else {
|
||||
alert("Error: " + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error performing cron action:", error);
|
||||
alert("Error performing action");
|
||||
} finally {
|
||||
setCronActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSetting = async (key, value) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ key, value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update local state
|
||||
setSettings(prev =>
|
||||
prev.map(setting =>
|
||||
setting.key === key ? { ...setting, value } : setting
|
||||
)
|
||||
);
|
||||
alert("Setting updated successfully!");
|
||||
} else {
|
||||
alert("Failed to update setting");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating setting:", error);
|
||||
alert("Error updating setting");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupUserChange = (userId) => {
|
||||
updateSetting("backup_notification_user_id", userId);
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-lg">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session || session.user.role !== "admin") {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-4">
|
||||
Access Denied
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
You need admin privileges to access this page.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const backupUserSetting = settings.find(s => s.key === "backup_notification_user_id");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Admin Settings
|
||||
</h1>
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
← Back to Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Backup Notifications Setting */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Backup Notifications
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Select which user should receive notifications when daily database backups are completed.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Notification Recipient
|
||||
</label>
|
||||
<select
|
||||
value={backupUserSetting?.value || ""}
|
||||
onChange={(e) => handleBackupUserChange(e.target.value)}
|
||||
disabled={saving}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="">No notifications</option>
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name} ({user.username}) - {user.role}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{saving && (
|
||||
<p className="text-sm text-blue-600">Saving...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future settings can be added here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Cron Jobs Status
|
||||
</h3>
|
||||
<button
|
||||
onClick={fetchCronStatus}
|
||||
disabled={cronLoading}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{cronLoading ? "Refreshing..." : "↻ Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cronLoading && !cronStatus ? (
|
||||
<p className="text-sm text-gray-500">Loading cron status...</p>
|
||||
) : cronStatus ? (
|
||||
<div className="space-y-4">
|
||||
{/* Status indicators */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
cronStatus.available
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
{cronStatus.available ? "✓ Cron Available" : "⚠ Cron Unavailable"}
|
||||
</div>
|
||||
{cronStatus.available && (
|
||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${
|
||||
cronStatus.running
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}>
|
||||
{cronStatus.running ? "✓ Daemon Running" : "✗ Daemon Not Running"}
|
||||
</div>
|
||||
)}
|
||||
{cronStatus.available && (
|
||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||
{cronStatus.jobCount || 0} Job(s) Scheduled
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scheduled jobs */}
|
||||
{cronStatus.jobs && cronStatus.jobs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-1">Scheduled Jobs:</p>
|
||||
<div className="bg-gray-50 rounded p-2 font-mono text-xs">
|
||||
{cronStatus.jobs.map((job, idx) => (
|
||||
<div key={idx} className="py-0.5">{job}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last backup info */}
|
||||
{cronStatus.lastBackup && (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-700">Last Backup: </span>
|
||||
{cronStatus.lastBackup.exists ? (
|
||||
<span className="text-green-600">
|
||||
{cronStatus.lastBackup.filename} ({new Date(cronStatus.lastBackup.date).toLocaleString()})
|
||||
<span className="text-gray-500 ml-2">
|
||||
({cronStatus.lastBackup.count} total backups)
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{cronStatus.lastBackup.message || "No backups"}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message for non-Linux environments */}
|
||||
{cronStatus.message && (
|
||||
<p className="text-sm text-yellow-600">{cronStatus.message}</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{cronStatus.available && (
|
||||
<button
|
||||
onClick={() => handleCronAction("restart")}
|
||||
disabled={cronActionLoading === "restart"}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{cronActionLoading === "restart" ? "Restarting..." : "🔄 Restart Cron Jobs"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleCronAction("run-backup")}
|
||||
disabled={cronActionLoading === "run-backup"}
|
||||
className="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{cronActionLoading === "run-backup" ? "Running..." : "💾 Run Backup Now"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCronAction("run-reminders")}
|
||||
disabled={cronActionLoading === "run-reminders"}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{cronActionLoading === "run-reminders" ? "Running..." : "📧 Send Reminders Now"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-red-500">Failed to load cron status</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* System Information */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
System Information
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Daily database backups run automatically at 2 AM and keep the last 30 backups.
|
||||
Backups are stored in the <code className="bg-gray-100 px-1 rounded">./backups/</code> directory.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ export default function EditUserPage() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
username: "",
|
||||
role: "user",
|
||||
is_active: true,
|
||||
initial: "",
|
||||
password: ""
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -62,9 +63,10 @@ export default function EditUserPage() {
|
||||
setUser(userData);
|
||||
setFormData({
|
||||
name: userData.name,
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
role: userData.role,
|
||||
is_active: userData.is_active,
|
||||
initial: userData.initial || "",
|
||||
password: "" // Never populate password field
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -84,9 +86,10 @@ export default function EditUserPage() {
|
||||
// Prepare update data (exclude empty password)
|
||||
const updateData = {
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
username: formData.username,
|
||||
role: formData.role,
|
||||
is_active: formData.is_active
|
||||
is_active: formData.is_active,
|
||||
initial: formData.initial.trim() || null
|
||||
};
|
||||
|
||||
// Only include password if it's provided
|
||||
@@ -209,12 +212,12 @@ export default function EditUserPage() {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email *
|
||||
Username *
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -232,6 +235,7 @@ export default function EditUserPage() {
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="team_lead">Team Lead</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -253,6 +257,23 @@ export default function EditUserPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Initial
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.initial}
|
||||
onChange={(e) => setFormData({ ...formData, initial: e.target.value })}
|
||||
placeholder="1-2 letter identifier"
|
||||
maxLength={2}
|
||||
className="w-full md:w-1/2"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Optional 1-2 letter identifier for the user
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -12,8 +12,10 @@ import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function UserManagementPage() {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
@@ -54,7 +56,7 @@ export default function UserManagementPage() {
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId) => {
|
||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
||||
if (!confirm(t('admin.deleteUser') + "?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
@@ -95,10 +97,36 @@ export default function UserManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAssignable = async (userId, canBeAssigned) => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ can_be_assigned: !canBeAssigned }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update user");
|
||||
}
|
||||
|
||||
setUsers(users.map(user =>
|
||||
user.id === userId
|
||||
? { ...user, can_be_assigned: !canBeAssigned }
|
||||
: user
|
||||
));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "red";
|
||||
case "team_lead":
|
||||
return "purple";
|
||||
case "project_manager":
|
||||
return "blue";
|
||||
case "user":
|
||||
@@ -114,6 +142,8 @@ export default function UserManagementPage() {
|
||||
switch (role) {
|
||||
case "project_manager":
|
||||
return "Project Manager";
|
||||
case "team_lead":
|
||||
return "Team Lead";
|
||||
case "read_only":
|
||||
return "Read Only";
|
||||
default:
|
||||
@@ -141,7 +171,7 @@ export default function UserManagementPage() {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="User Management" description="Manage system users and permissions">
|
||||
<PageHeader title={t('admin.userManagement')} description={t('admin.subtitle')}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
@@ -149,7 +179,7 @@ export default function UserManagementPage() {
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add User
|
||||
{t('admin.newUser')}
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
@@ -192,7 +222,10 @@ export default function UserManagementPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-sm text-gray-500">{user.username}</p>
|
||||
{user.initial && (
|
||||
<p className="text-xs text-blue-600 font-medium mt-1">Initial: {user.initial}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -202,6 +235,9 @@ export default function UserManagementPage() {
|
||||
<Badge color={user.is_active ? "green" : "red"}>
|
||||
{user.is_active ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
<Badge color={user.can_be_assigned ? "blue" : "gray"}>
|
||||
{user.can_be_assigned ? "Assignable" : "Not Assignable"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -232,6 +268,20 @@ export default function UserManagementPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`assignable-${user.id}`}
|
||||
checked={user.can_be_assigned || false}
|
||||
onChange={() => handleToggleAssignable(user.id, user.can_be_assigned)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor={`assignable-${user.id}`} className="text-sm text-gray-700">
|
||||
Can be assigned to projects/tasks
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -282,10 +332,11 @@ export default function UserManagementPage() {
|
||||
function CreateUserModal({ onClose, onUserCreated }) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "user",
|
||||
is_active: true
|
||||
is_active: true,
|
||||
can_be_assigned: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -351,12 +402,12 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -386,6 +437,7 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
<option value="read_only">Read Only</option>
|
||||
<option value="user">User</option>
|
||||
<option value="project_manager">Project Manager</option>
|
||||
<option value="team_lead">Team Lead</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -403,6 +455,19 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="can_be_assigned"
|
||||
checked={formData.can_be_assigned}
|
||||
onChange={(e) => setFormData({ ...formData, can_be_assigned: e.target.checked })}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="can_be_assigned" className="ml-2 block text-sm text-gray-900">
|
||||
Can be assigned to projects/tasks
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button type="submit" disabled={loading} className="flex-1">
|
||||
{loading ? "Creating..." : "Create User"}
|
||||
|
||||
191
src/app/api/admin/cron/route.js
Normal file
191
src/app/api/admin/cron/route.js
Normal file
@@ -0,0 +1,191 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Check if we're running in a Linux/Docker environment
|
||||
const isLinux = process.platform === "linux";
|
||||
|
||||
async function getCronStatus() {
|
||||
if (!isLinux) {
|
||||
return {
|
||||
available: false,
|
||||
running: false,
|
||||
jobs: [],
|
||||
message: "Cron is only available in Linux/Docker environment",
|
||||
lastBackup: getLastBackupInfo(),
|
||||
lastReminder: getLastReminderInfo()
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if cron daemon is running
|
||||
let cronRunning = false;
|
||||
try {
|
||||
await execAsync("pgrep -x cron || pgrep -x crond");
|
||||
cronRunning = true;
|
||||
} catch {
|
||||
cronRunning = false;
|
||||
}
|
||||
|
||||
// Get current crontab
|
||||
let jobs = [];
|
||||
try {
|
||||
const { stdout } = await execAsync("crontab -l 2>/dev/null");
|
||||
jobs = stdout.trim().split("\n").filter(line => line && !line.startsWith("#"));
|
||||
} catch {
|
||||
jobs = [];
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
running: cronRunning,
|
||||
jobs: jobs,
|
||||
jobCount: jobs.length,
|
||||
lastBackup: getLastBackupInfo(),
|
||||
lastReminder: getLastReminderInfo()
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
running: false,
|
||||
jobs: [],
|
||||
error: error.message,
|
||||
lastBackup: getLastBackupInfo(),
|
||||
lastReminder: getLastReminderInfo()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getLastBackupInfo() {
|
||||
try {
|
||||
const backupDir = path.join(process.cwd(), "backups");
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
return { exists: false, message: "No backups directory" };
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(backupDir)
|
||||
.filter(f => f.startsWith("backup-") && f.endsWith(".sqlite"))
|
||||
.map(f => ({
|
||||
name: f,
|
||||
path: path.join(backupDir, f),
|
||||
mtime: fs.statSync(path.join(backupDir, f)).mtime
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
if (files.length === 0) {
|
||||
return { exists: false, message: "No backups found" };
|
||||
}
|
||||
|
||||
const latest = files[0];
|
||||
return {
|
||||
exists: true,
|
||||
filename: latest.name,
|
||||
date: latest.mtime.toISOString(),
|
||||
count: files.length
|
||||
};
|
||||
} catch (error) {
|
||||
return { exists: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function getLastReminderInfo() {
|
||||
try {
|
||||
const logPath = path.join(process.cwd(), "data", "reminders.log");
|
||||
if (!fs.existsSync(logPath)) {
|
||||
return { exists: false, message: "No reminders log" };
|
||||
}
|
||||
|
||||
const stats = fs.statSync(logPath);
|
||||
return {
|
||||
exists: true,
|
||||
lastModified: stats.mtime.toISOString()
|
||||
};
|
||||
} catch (error) {
|
||||
return { exists: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function getHandler() {
|
||||
const status = await getCronStatus();
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
|
||||
async function postHandler(request) {
|
||||
const { action } = await request.json();
|
||||
|
||||
if (!isLinux) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: "Cron operations are only available in Linux/Docker environment"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "restart") {
|
||||
// Run the setup-cron.sh script
|
||||
const scriptPath = path.join(process.cwd(), "setup-cron.sh");
|
||||
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: "setup-cron.sh script not found"
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
// Make sure script is executable
|
||||
await execAsync(`chmod +x ${scriptPath}`);
|
||||
|
||||
// Run the script
|
||||
const { stdout, stderr } = await execAsync(`bash ${scriptPath}`);
|
||||
|
||||
// Get updated status
|
||||
const status = await getCronStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cron jobs restarted successfully",
|
||||
output: stdout,
|
||||
status
|
||||
});
|
||||
} else if (action === "run-backup") {
|
||||
// Manually trigger backup
|
||||
const backupScript = path.join(process.cwd(), "backup-db.mjs");
|
||||
const { stdout } = await execAsync(`cd ${process.cwd()} && node ${backupScript}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Backup completed",
|
||||
output: stdout
|
||||
});
|
||||
} else if (action === "run-reminders") {
|
||||
// Manually trigger reminders
|
||||
const reminderScript = path.join(process.cwd(), "send-due-date-reminders.mjs");
|
||||
const { stdout } = await execAsync(`cd ${process.cwd()} && node ${reminderScript}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Reminders sent",
|
||||
output: stdout
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: "Unknown action"
|
||||
}, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
message: error.message,
|
||||
stderr: error.stderr
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAdminAuth(getHandler);
|
||||
export const POST = withAdminAuth(postHandler);
|
||||
52
src/app/api/admin/settings/route.js
Normal file
52
src/app/api/admin/settings/route.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
import db from "@/lib/db";
|
||||
|
||||
// GET: Get all settings
|
||||
async function getSettingsHandler() {
|
||||
try {
|
||||
const settings = db.prepare("SELECT * FROM settings ORDER BY key").all();
|
||||
return NextResponse.json(settings);
|
||||
} catch (error) {
|
||||
console.error("Error fetching settings:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update a setting
|
||||
async function updateSettingHandler(request) {
|
||||
try {
|
||||
const { key, value } = await request.json();
|
||||
|
||||
if (!key || value === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Key and value are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updatedBy = request.user.id;
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR REPLACE INTO settings (key, value, updated_at, updated_by)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, ?)
|
||||
`);
|
||||
|
||||
stmt.run(key, value, updatedBy);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating setting:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update setting" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require admin authentication
|
||||
export const GET = withAdminAuth(getSettingsHandler);
|
||||
export const PUT = withAdminAuth(updateSettingHandler);
|
||||
@@ -4,8 +4,9 @@ import { withAdminAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get user by ID (admin only)
|
||||
async function getUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const user = getUserById(params.id);
|
||||
const user = getUserById(id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
@@ -29,9 +30,10 @@ async function getUserHandler(req, { params }) {
|
||||
|
||||
// PUT: Update user (admin only)
|
||||
async function updateUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const data = await req.json();
|
||||
const userId = params.id;
|
||||
const userId = id;
|
||||
|
||||
// Prevent admin from deactivating themselves
|
||||
if (data.is_active === false && userId === req.user.id) {
|
||||
@@ -43,7 +45,7 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
// Validate role if provided
|
||||
if (data.role) {
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
|
||||
if (!validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
@@ -78,7 +80,7 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ error: "A user with this username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
@@ -92,8 +94,9 @@ async function updateUserHandler(req, { params }) {
|
||||
|
||||
// DELETE: Delete user (admin only)
|
||||
async function deleteUserHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const userId = params.id;
|
||||
const userId = id;
|
||||
|
||||
// Prevent admin from deleting themselves
|
||||
if (userId === req.user.id) {
|
||||
|
||||
@@ -27,9 +27,9 @@ async function createUserHandler(req) {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name || !data.email || !data.password) {
|
||||
if (!data.name || !data.username || !data.password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name, email, and password are required" },
|
||||
{ error: "Name, username, and password are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ async function createUserHandler(req) {
|
||||
}
|
||||
|
||||
// Validate role
|
||||
const validRoles = ["read_only", "user", "project_manager", "admin"];
|
||||
const validRoles = ["read_only", "user", "project_manager", "team_lead", "admin"];
|
||||
if (data.role && !validRoles.includes(data.role)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid role specified" },
|
||||
@@ -53,7 +53,7 @@ async function createUserHandler(req) {
|
||||
|
||||
const newUser = await createUser({
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
role: data.role || "user",
|
||||
is_active: data.is_active !== undefined ? data.is_active : true
|
||||
@@ -68,7 +68,7 @@ async function createUserHandler(req) {
|
||||
|
||||
if (error.message.includes("already exists")) {
|
||||
return NextResponse.json(
|
||||
{ error: "A user with this email already exists" },
|
||||
{ error: "A user with this username already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
80
src/app/api/auth/change-password/route.js
Normal file
80
src/app/api/auth/change-password/route.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1, "Current password is required"),
|
||||
newPassword: z.string().min(6, "New password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { currentPassword, newPassword } = changePasswordSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Get current user password hash
|
||||
const user = db
|
||||
.prepare("SELECT password_hash FROM users WHERE id = ?")
|
||||
.get(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: "User not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current password is incorrect" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(hashedNewPassword, session.user.id);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.USER_UPDATE,
|
||||
userId: session.user.id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { field: "password", username: session.user.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Password changed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Change password error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
src/app/api/auth/password-reset/request/route.js
Normal file
70
src/app/api/auth/password-reset/request/route.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
const requestSchema = z.object({
|
||||
username: z.string().min(1, "Username is required"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username } = requestSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if user exists and is active
|
||||
const user = db
|
||||
.prepare("SELECT id, username, name FROM users WHERE username = ? AND is_active = 1")
|
||||
.get(username);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal if user exists or not for security
|
||||
return NextResponse.json(
|
||||
{ message: "If the username exists, a password reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
// Generate reset token
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour
|
||||
|
||||
// Delete any existing tokens for this user
|
||||
db.prepare("DELETE FROM password_reset_tokens WHERE user_id = ?").run(user.id);
|
||||
|
||||
// Insert new token
|
||||
db.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
).run(user.id, token, expiresAt);
|
||||
|
||||
// TODO: Send email with reset link
|
||||
// For now, return the token for testing purposes
|
||||
console.log(`Password reset token for ${username}: ${token}`);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.PASSWORD_RESET_REQUEST,
|
||||
userId: user.id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { username: user.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "If the username exists, a password reset link has been sent." },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Password reset request error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/app/api/auth/password-reset/reset/route.js
Normal file
71
src/app/api/auth/password-reset/reset/route.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
|
||||
const resetSchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token, password } = resetSchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if token exists and is valid
|
||||
const resetToken = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT prt.*, u.username, u.name
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
|
||||
`
|
||||
)
|
||||
.get(token);
|
||||
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Hash the new password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update user password
|
||||
db.prepare("UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
|
||||
.run(hashedPassword, resetToken.user_id);
|
||||
|
||||
// Mark token as used
|
||||
db.prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?")
|
||||
.run(resetToken.id);
|
||||
|
||||
// Log audit event
|
||||
try {
|
||||
const { logAuditEventSafe, AUDIT_ACTIONS, RESOURCE_TYPES } = await import("@/lib/auditLogSafe.js");
|
||||
await logAuditEventSafe({
|
||||
action: AUDIT_ACTIONS.PASSWORD_RESET,
|
||||
userId: resetToken.user_id,
|
||||
resourceType: RESOURCE_TYPES.USER,
|
||||
details: { username: resetToken.username },
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to log audit event:", auditError);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: "Password has been reset successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Password reset error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
47
src/app/api/auth/password-reset/verify/route.js
Normal file
47
src/app/api/auth/password-reset/verify/route.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const verifySchema = z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
});
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token } = verifySchema.parse(body);
|
||||
|
||||
// Import database here to avoid edge runtime issues
|
||||
const { default: db } = await import("@/lib/db.js");
|
||||
|
||||
// Check if token exists and is valid
|
||||
const resetToken = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT prt.*, u.username, u.name
|
||||
FROM password_reset_tokens prt
|
||||
JOIN users u ON prt.user_id = u.id
|
||||
WHERE prt.token = ? AND prt.used = 0 AND prt.expires_at > datetime('now')
|
||||
`
|
||||
)
|
||||
.get(token);
|
||||
|
||||
if (!resetToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or expired token" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
valid: true,
|
||||
username: resetToken.username,
|
||||
name: resetToken.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Token verification error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
40
src/app/api/contacts/[id]/projects/route.js
Normal file
40
src/app/api/contacts/[id]/projects/route.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import db from "@/lib/db";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET /api/contacts/[id]/projects - Get all projects linked to a contact
|
||||
export const GET = withAuth(async (request, { params }) => {
|
||||
try {
|
||||
const contactId = params.id;
|
||||
|
||||
// Get all projects linked to this contact with relationship details
|
||||
const projects = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
p.project_id,
|
||||
p.project_name,
|
||||
p.project_status,
|
||||
pc.relationship_type,
|
||||
pc.is_primary,
|
||||
pc.added_at
|
||||
FROM projects p
|
||||
INNER JOIN project_contacts pc ON p.project_id = pc.project_id
|
||||
WHERE pc.contact_id = ?
|
||||
ORDER BY pc.is_primary DESC, p.project_name ASC
|
||||
`
|
||||
)
|
||||
.all(contactId);
|
||||
|
||||
return NextResponse.json({
|
||||
projects,
|
||||
count: projects.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch projects" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
111
src/app/api/contacts/[id]/route.js
Normal file
111
src/app/api/contacts/[id]/route.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getContactById,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
hardDeleteContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
import { syncContactAsync, deleteContactAsync } from "@/lib/radicale-sync";
|
||||
|
||||
// GET: Get contact by ID
|
||||
async function getContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const contact = getContactById(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update contact
|
||||
async function updateContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const data = await req.json();
|
||||
|
||||
// Validate contact type if provided
|
||||
if (data.contact_type) {
|
||||
const validTypes = [
|
||||
"project",
|
||||
"contractor",
|
||||
"office",
|
||||
"supplier",
|
||||
"other",
|
||||
];
|
||||
if (!validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contact = updateContact(contactId, data);
|
||||
|
||||
if (!contact) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sync to Radicale asynchronously (non-blocking)
|
||||
syncContactAsync(contact);
|
||||
|
||||
return NextResponse.json(contact);
|
||||
} catch (error) {
|
||||
console.error("Error updating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete contact (soft delete or hard delete)
|
||||
async function deleteContactHandler(req, { params }) {
|
||||
try {
|
||||
const contactId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const hard = searchParams.get("hard") === "true";
|
||||
|
||||
if (hard) {
|
||||
// Hard delete - permanently remove
|
||||
hardDeleteContact(contactId);
|
||||
// Delete from Radicale asynchronously
|
||||
deleteContactAsync(contactId);
|
||||
} else {
|
||||
// Soft delete - set is_active to 0
|
||||
deleteContact(contactId);
|
||||
// Delete from Radicale asynchronously
|
||||
deleteContactAsync(contactId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ message: "Contact deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactHandler);
|
||||
export const PUT = withAuth(updateContactHandler);
|
||||
export const DELETE = withAuth(deleteContactHandler);
|
||||
78
src/app/api/contacts/route.js
Normal file
78
src/app/api/contacts/route.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAllContacts,
|
||||
createContact,
|
||||
getContactStats,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
import { syncContactAsync } from "@/lib/radicale-sync";
|
||||
|
||||
// GET: Get all contacts with optional filters
|
||||
async function getContactsHandler(req) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const filters = {
|
||||
is_active: searchParams.get("is_active")
|
||||
? searchParams.get("is_active") === "true"
|
||||
: undefined,
|
||||
contact_type: searchParams.get("contact_type") || undefined,
|
||||
search: searchParams.get("search") || undefined,
|
||||
};
|
||||
|
||||
// Check if stats are requested
|
||||
if (searchParams.get("stats") === "true") {
|
||||
const stats = getContactStats();
|
||||
return NextResponse.json(stats);
|
||||
}
|
||||
|
||||
const contacts = getAllContacts(filters);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create new contact
|
||||
async function createContactHandler(req) {
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (!data.name) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate contact type
|
||||
const validTypes = ["project", "contractor", "office", "supplier", "other"];
|
||||
if (data.contact_type && !validTypes.includes(data.contact_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid contact type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const contact = createContact(data);
|
||||
|
||||
// Sync to Radicale asynchronously (non-blocking)
|
||||
syncContactAsync(contact);
|
||||
|
||||
return NextResponse.json(contact, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withAuth(getContactsHandler);
|
||||
export const POST = withAuth(createContactHandler);
|
||||
@@ -10,6 +10,7 @@ async function getContractsHandler() {
|
||||
contract_id,
|
||||
contract_number,
|
||||
contract_name,
|
||||
customer_contract_number,
|
||||
customer,
|
||||
investor,
|
||||
date_signed,
|
||||
@@ -24,7 +25,7 @@ async function getContractsHandler() {
|
||||
|
||||
async function createContractHandler(req) {
|
||||
const data = await req.json();
|
||||
db.prepare(
|
||||
const result = db.prepare(
|
||||
`
|
||||
INSERT INTO contracts (
|
||||
contract_number,
|
||||
@@ -45,7 +46,10 @@ async function createContractHandler(req) {
|
||||
data.date_signed,
|
||||
data.finish_date
|
||||
);
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
// Return the newly created contract with its ID
|
||||
const contract = db.prepare("SELECT * FROM contracts WHERE contract_id = ?").get(result.lastInsertRowid);
|
||||
return NextResponse.json(contract);
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
|
||||
262
src/app/api/dashboard/route.js
Normal file
262
src/app/api/dashboard/route.js
Normal file
@@ -0,0 +1,262 @@
|
||||
// Force this API route to use Node.js runtime
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getAllProjects } from "@/lib/queries/projects";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Only team leads can access dashboard data
|
||||
if (session.user.role !== 'team_lead') {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const selectedYear = searchParams.get('year') ? parseInt(searchParams.get('year')) : null;
|
||||
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Calculate realised and unrealised values by project type
|
||||
const projectTypes = ['design', 'design+construction', 'construction'];
|
||||
const typeSummary = {};
|
||||
|
||||
projectTypes.forEach(type => {
|
||||
typeSummary[type] = {
|
||||
realisedValue: 0,
|
||||
unrealisedValue: 0
|
||||
};
|
||||
});
|
||||
|
||||
projects.forEach(project => {
|
||||
const value = parseFloat(project.wartosc_zlecenia) || 0;
|
||||
const type = project.project_type;
|
||||
|
||||
if (!type || !projectTypes.includes(type)) return;
|
||||
|
||||
if (project.project_status === 'fulfilled' && project.completion_date && project.wartosc_zlecenia) {
|
||||
typeSummary[type].realisedValue += value;
|
||||
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
||||
typeSummary[type].unrealisedValue += value;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate overall totals
|
||||
let realisedValue = 0;
|
||||
let unrealisedValue = 0;
|
||||
|
||||
Object.values(typeSummary).forEach(summary => {
|
||||
realisedValue += summary.realisedValue;
|
||||
unrealisedValue += summary.unrealisedValue;
|
||||
});
|
||||
|
||||
// Filter completed projects (those with completion_date and fulfilled status)
|
||||
const completedProjects = projects.filter(project =>
|
||||
project.completion_date &&
|
||||
project.wartosc_zlecenia &&
|
||||
project.project_status === 'fulfilled'
|
||||
);
|
||||
|
||||
// If no data, return sample data for demonstration
|
||||
let chartData;
|
||||
let summary;
|
||||
if (completedProjects.length === 0) {
|
||||
// Generate continuous sample data based on selected year or default range
|
||||
const currentDate = new Date();
|
||||
let startDate, endDate;
|
||||
|
||||
if (selectedYear) {
|
||||
startDate = new Date(selectedYear, 0, 1); // Jan 1st of selected year
|
||||
endDate = new Date(selectedYear, 11, 31); // Dec 31st of selected year
|
||||
if (endDate > currentDate) endDate = currentDate;
|
||||
} else {
|
||||
startDate = new Date(2024, 0, 1); // Jan 2024
|
||||
endDate = currentDate;
|
||||
}
|
||||
|
||||
chartData = [];
|
||||
let cumulative = 0;
|
||||
|
||||
let tempDate = new Date(startDate);
|
||||
while (tempDate <= endDate) {
|
||||
const monthName = tempDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
let monthlyValue = 0;
|
||||
|
||||
// Add some sample values for certain months (only if they match the selected year or no year selected)
|
||||
const shouldAddData = !selectedYear || tempDate.getFullYear() === selectedYear;
|
||||
|
||||
if (shouldAddData) {
|
||||
if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2024) monthlyValue = 50000; // Jan 2024
|
||||
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2024) monthlyValue = 75000; // Feb 2024
|
||||
else if (tempDate.getMonth() === 2 && tempDate.getFullYear() === 2024) monthlyValue = 60000; // Mar 2024
|
||||
else if (tempDate.getMonth() === 7 && tempDate.getFullYear() === 2024) monthlyValue = 10841; // Aug 2024 (real data)
|
||||
else if (tempDate.getMonth() === 8 && tempDate.getFullYear() === 2024) monthlyValue = 18942; // Sep 2024
|
||||
else if (tempDate.getMonth() === 9 && tempDate.getFullYear() === 2024) monthlyValue = 13945; // Oct 2024
|
||||
else if (tempDate.getMonth() === 10 && tempDate.getFullYear() === 2024) monthlyValue = 12542; // Nov 2024
|
||||
else if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2025) monthlyValue = 25000; // Jan 2025
|
||||
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2025) monthlyValue = 35000; // Feb 2025
|
||||
}
|
||||
|
||||
cumulative += monthlyValue;
|
||||
chartData.push({
|
||||
month: monthName,
|
||||
value: monthlyValue,
|
||||
cumulative: cumulative
|
||||
});
|
||||
|
||||
tempDate.setMonth(tempDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
summary = {
|
||||
total: {
|
||||
realisedValue: 958000,
|
||||
unrealisedValue: 1242000
|
||||
},
|
||||
byType: {
|
||||
design: {
|
||||
realisedValue: 320000,
|
||||
unrealisedValue: 480000
|
||||
},
|
||||
'design+construction': {
|
||||
realisedValue: 480000,
|
||||
unrealisedValue: 520000
|
||||
},
|
||||
construction: {
|
||||
realisedValue: 158000,
|
||||
unrealisedValue: 242000
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Group by month and calculate monthly totals first
|
||||
const monthlyData = {};
|
||||
|
||||
// Sort projects by completion date
|
||||
completedProjects.sort((a, b) => new Date(a.completion_date) - new Date(b.completion_date));
|
||||
|
||||
// First pass: calculate monthly totals
|
||||
completedProjects.forEach(project => {
|
||||
const date = new Date(project.completion_date);
|
||||
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthName = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
|
||||
if (!monthlyData[monthKey]) {
|
||||
monthlyData[monthKey] = {
|
||||
month: monthName,
|
||||
value: 0
|
||||
};
|
||||
}
|
||||
|
||||
const projectValue = parseFloat(project.wartosc_zlecenia) || 0;
|
||||
monthlyData[monthKey].value += projectValue;
|
||||
});
|
||||
|
||||
// Generate continuous timeline from earliest completion to current date
|
||||
let startDate = new Date();
|
||||
let endDate = new Date();
|
||||
|
||||
if (completedProjects.length > 0) {
|
||||
// Find earliest completion date
|
||||
const earliestCompletion = completedProjects.reduce((earliest, project) => {
|
||||
const projectDate = new Date(project.completion_date);
|
||||
return projectDate < earliest ? projectDate : earliest;
|
||||
}, new Date());
|
||||
|
||||
startDate = new Date(earliestCompletion.getFullYear(), earliestCompletion.getMonth(), 1);
|
||||
} else {
|
||||
// If no completed projects, start from 6 months ago
|
||||
startDate = new Date();
|
||||
startDate.setMonth(startDate.getMonth() - 6);
|
||||
startDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
|
||||
}
|
||||
|
||||
// If a specific year is selected, adjust the date range
|
||||
if (selectedYear) {
|
||||
startDate = new Date(selectedYear, 0, 1); // January 1st of selected year
|
||||
endDate = new Date(selectedYear, 11, 31); // December 31st of selected year
|
||||
|
||||
// Don't go beyond current date
|
||||
if (endDate > new Date()) {
|
||||
endDate = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate all months from start to current
|
||||
const allMonths = {};
|
||||
let currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const monthKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthName = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
||||
|
||||
allMonths[monthKey] = {
|
||||
month: monthName,
|
||||
value: monthlyData[monthKey]?.value || 0
|
||||
};
|
||||
|
||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
||||
}
|
||||
|
||||
// Calculate cumulative values
|
||||
let cumulativeValue = 0;
|
||||
const sortedMonths = Object.keys(allMonths).sort((a, b) => a.localeCompare(b));
|
||||
|
||||
sortedMonths.forEach(monthKey => {
|
||||
cumulativeValue += allMonths[monthKey].value;
|
||||
allMonths[monthKey].cumulative = cumulativeValue;
|
||||
});
|
||||
|
||||
// Convert to array
|
||||
chartData = sortedMonths.map(monthKey => ({
|
||||
month: allMonths[monthKey].month,
|
||||
value: Math.round(allMonths[monthKey].value),
|
||||
cumulative: Math.round(allMonths[monthKey].cumulative)
|
||||
}));
|
||||
summary = {
|
||||
total: {
|
||||
realisedValue: Math.round(realisedValue),
|
||||
unrealisedValue: Math.round(unrealisedValue)
|
||||
},
|
||||
byType: Object.fromEntries(
|
||||
Object.entries(typeSummary).map(([type, data]) => [
|
||||
type,
|
||||
{
|
||||
realisedValue: Math.round(data.realisedValue),
|
||||
unrealisedValue: Math.round(data.unrealisedValue)
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
chartData,
|
||||
summary: {
|
||||
total: {
|
||||
realisedValue: Math.round(realisedValue),
|
||||
unrealisedValue: Math.round(unrealisedValue)
|
||||
},
|
||||
byType: Object.fromEntries(
|
||||
Object.entries(typeSummary).map(([type, data]) => [
|
||||
type,
|
||||
{
|
||||
realisedValue: Math.round(data.realisedValue),
|
||||
unrealisedValue: Math.round(data.unrealisedValue)
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Dashboard API error:', error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
46
src/app/api/field-history/route.js
Normal file
46
src/app/api/field-history/route.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { getFieldHistory, hasFieldHistory } from "@/lib/queries/fieldHistory";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function getFieldHistoryHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const tableName = searchParams.get("table_name");
|
||||
const recordId = searchParams.get("record_id");
|
||||
const fieldName = searchParams.get("field_name");
|
||||
const checkOnly = searchParams.get("check_only") === "true";
|
||||
|
||||
if (!tableName || !recordId || !fieldName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters: table_name, record_id, field_name" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (checkOnly) {
|
||||
// Just check if history exists
|
||||
const exists = hasFieldHistory(tableName, parseInt(recordId), fieldName);
|
||||
return NextResponse.json({ hasHistory: exists });
|
||||
} else {
|
||||
// Get full history
|
||||
const history = getFieldHistory(tableName, parseInt(recordId), fieldName);
|
||||
return NextResponse.json(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching field history:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch field history" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require read authentication
|
||||
export const GET = withReadAuth(getFieldHistoryHandler);
|
||||
186
src/app/api/files/[fileId]/route.js
Normal file
186
src/app/api/files/[fileId]/route.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import { unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
|
||||
try {
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the full file path
|
||||
const fullPath = path.join(process.cwd(), "public", file.file_path);
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(fullPath)) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found on disk" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file
|
||||
const fileBuffer = await readFile(fullPath);
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": file.mime_type || "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(file.original_filename)}"`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error downloading file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to download file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { description, original_filename } = body;
|
||||
|
||||
// Validate input
|
||||
if (description !== undefined && typeof description !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: "Description must be a string" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (original_filename !== undefined && typeof original_filename !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ error: "Original filename must be a string" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const existingFile = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!existingFile) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Build update query
|
||||
const updates = [];
|
||||
const values = [];
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push('description = ?');
|
||||
values.push(description);
|
||||
}
|
||||
|
||||
if (original_filename !== undefined) {
|
||||
updates.push('original_filename = ?');
|
||||
values.push(original_filename);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid fields to update" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
values.push(parseInt(fileId));
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE file_attachments
|
||||
SET ${updates.join(', ')}
|
||||
WHERE file_id = ?
|
||||
`).run(...values);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get updated file
|
||||
const updatedFile = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
return NextResponse.json(updatedFile);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error updating file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
const { fileId } = await params;
|
||||
try {
|
||||
// Get file info from database
|
||||
const file = db.prepare(`
|
||||
SELECT * FROM file_attachments WHERE file_id = ?
|
||||
`).get(parseInt(fileId));
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete physical file
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), "public", file.file_path);
|
||||
await unlink(fullPath);
|
||||
} catch (fileError) {
|
||||
console.warn("Could not delete physical file:", fileError.message);
|
||||
// Continue with database deletion even if file doesn't exist
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
const result = db.prepare(`
|
||||
DELETE FROM file_attachments WHERE file_id = ?
|
||||
`).run(parseInt(fileId));
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "File not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
162
src/app/api/files/route.js
Normal file
162
src/app/api/files/route.js
Normal file
@@ -0,0 +1,162 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
import { auditLog } from "@/lib/middleware/auditLog";
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
"application/pdf",
|
||||
"application/msword",
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"application/vnd.ms-excel",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"text/plain"
|
||||
];
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const entityType = formData.get("entityType");
|
||||
const entityId = formData.get("entityId");
|
||||
const description = formData.get("description") || "";
|
||||
|
||||
if (!file || !entityType || !entityId) {
|
||||
return NextResponse.json(
|
||||
{ error: "File, entityType, and entityId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate entity type
|
||||
if (!["contract", "project", "task"].includes(entityType)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid entity type" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "File size too large (max 10MB)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "File type not allowed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create upload directory structure
|
||||
const entityDir = path.join(UPLOAD_DIR, entityType + "s", entityId);
|
||||
if (!existsSync(entityDir)) {
|
||||
await mkdir(entityDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const sanitizedOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const storedFilename = `${timestamp}_${sanitizedOriginalName}`;
|
||||
const filePath = path.join(entityDir, storedFilename);
|
||||
const relativePath = `/uploads/${entityType}s/${entityId}/${storedFilename}`;
|
||||
|
||||
// Save file
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Save to database
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO file_attachments (
|
||||
entity_type, entity_id, original_filename, stored_filename,
|
||||
file_path, file_size, mime_type, description, uploaded_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
entityType,
|
||||
parseInt(entityId),
|
||||
file.name,
|
||||
storedFilename,
|
||||
relativePath,
|
||||
file.size,
|
||||
file.type,
|
||||
description,
|
||||
null // TODO: Get from session when auth is implemented
|
||||
);
|
||||
|
||||
const newFile = {
|
||||
file_id: result.lastInsertRowid,
|
||||
entity_type: entityType,
|
||||
entity_id: parseInt(entityId),
|
||||
original_filename: file.name,
|
||||
stored_filename: storedFilename,
|
||||
file_path: relativePath,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
description: description,
|
||||
upload_date: new Date().toISOString()
|
||||
};
|
||||
|
||||
return NextResponse.json(newFile, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("File upload error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to upload file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const entityType = searchParams.get("entityType");
|
||||
const entityId = searchParams.get("entityId");
|
||||
|
||||
if (!entityType || !entityId) {
|
||||
return NextResponse.json(
|
||||
{ error: "entityType and entityId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const files = db.prepare(`
|
||||
SELECT
|
||||
file_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
description,
|
||||
upload_date,
|
||||
uploaded_by
|
||||
FROM file_attachments
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY upload_date DESC
|
||||
`).all(entityType, parseInt(entityId));
|
||||
|
||||
return NextResponse.json(files);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching files:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch files" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
137
src/app/api/notes/[id]/route.js
Normal file
137
src/app/api/notes/[id]/route.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Note ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: "Note not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this note
|
||||
// Users can delete their own notes, or admins can delete any note
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (userRole !== 'admin' && note.created_by !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Delete the note
|
||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||
|
||||
// Log note deletion
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_DELETE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
id,
|
||||
req.auth,
|
||||
{
|
||||
deletedNote: {
|
||||
project_id: note?.project_id,
|
||||
task_id: note?.task_id,
|
||||
note_length: note?.note?.length || 0,
|
||||
created_by: note?.created_by,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete note", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNoteHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const noteId = id;
|
||||
const { note: noteText } = await req.json();
|
||||
|
||||
if (!noteText || !noteId) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get original note for audit log and permission check
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
if (!originalNote) {
|
||||
return NextResponse.json({ error: "Note not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has permission to update this note
|
||||
// Users can update their own notes, or admins can update any note
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (userRole !== 'admin' && originalNote.created_by !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized to update this note" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Update the note
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ?, edited_at = datetime('now', 'localtime') WHERE note_id = ?
|
||||
`
|
||||
).run(noteText, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.auth,
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: noteText.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update note", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require user authentication
|
||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||
export const PUT = withUserAuth(updateNoteHandler);
|
||||
@@ -3,13 +3,59 @@ export const runtime = "nodejs";
|
||||
|
||||
import db from "@/lib/db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
async function getNotesHandler(req) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const projectId = searchParams.get("project_id");
|
||||
const taskId = searchParams.get("task_id");
|
||||
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (projectId) {
|
||||
query = `
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.project_id = ?
|
||||
ORDER BY n.note_date DESC
|
||||
`;
|
||||
params = [projectId];
|
||||
} else if (taskId) {
|
||||
query = `
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.task_id = ?
|
||||
ORDER BY n.note_date DESC
|
||||
`;
|
||||
params = [taskId];
|
||||
} else {
|
||||
return NextResponse.json({ error: "project_id or task_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const notes = db.prepare(query).all(...params);
|
||||
return NextResponse.json(notes);
|
||||
} catch (error) {
|
||||
console.error("Error fetching notes:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch notes" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function createNoteHandler(req) {
|
||||
const { project_id, task_id, note } = await req.json();
|
||||
|
||||
@@ -22,11 +68,25 @@ async function createNoteHandler(req) {
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
|
||||
`
|
||||
)
|
||||
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||
|
||||
// Get the created note with user info
|
||||
const createdNote = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT n.*,
|
||||
u.name as created_by_name,
|
||||
u.username as created_by_username
|
||||
FROM notes n
|
||||
LEFT JOIN users u ON n.created_by = u.id
|
||||
WHERE n.note_id = ?
|
||||
`
|
||||
)
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
// Log note creation
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
@@ -39,7 +99,7 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json(createdNote);
|
||||
} catch (error) {
|
||||
console.error("Error creating note:", error);
|
||||
return NextResponse.json(
|
||||
@@ -50,7 +110,7 @@ async function createNoteHandler(req) {
|
||||
}
|
||||
|
||||
async function deleteNoteHandler(req, { params }) {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
|
||||
// Get note data before deletion for audit log
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
@@ -76,48 +136,7 @@ async function deleteNoteHandler(req, { params }) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
async function updateNoteHandler(req, { params }) {
|
||||
const noteId = params.id;
|
||||
const { note } = await req.json();
|
||||
|
||||
if (!note || !noteId) {
|
||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get original note for audit log
|
||||
const originalNote = db
|
||||
.prepare("SELECT * FROM notes WHERE note_id = ?")
|
||||
.get(noteId);
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE notes SET note = ? WHERE note_id = ?
|
||||
`
|
||||
).run(note, noteId);
|
||||
|
||||
// Log note update
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.NOTE_UPDATE,
|
||||
RESOURCE_TYPES.NOTE,
|
||||
noteId,
|
||||
req.auth, // Use req.auth instead of req.session
|
||||
{
|
||||
originalNote: {
|
||||
note_length: originalNote?.note?.length || 0,
|
||||
project_id: originalNote?.project_id,
|
||||
task_id: originalNote?.task_id,
|
||||
},
|
||||
updatedNote: {
|
||||
note_length: note.length,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getNotesHandler);
|
||||
export const POST = withUserAuth(createNoteHandler);
|
||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
||||
export const PUT = withUserAuth(updateNoteHandler);
|
||||
|
||||
73
src/app/api/notifications/route.js
Normal file
73
src/app/api/notifications/route.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import {
|
||||
getUserNotifications,
|
||||
markNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
} from "@/lib/notifications";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const includeRead = searchParams.get("includeRead") === "true";
|
||||
const limit = parseInt(searchParams.get("limit") || "50");
|
||||
const offset = parseInt(searchParams.get("offset") || "0");
|
||||
|
||||
const notifications = await getUserNotifications(session.user.id, {
|
||||
includeRead,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
notifications,
|
||||
unreadCount,
|
||||
hasMore: notifications.length === limit,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching notifications:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action, notificationIds } = body;
|
||||
|
||||
if (action === "markAsRead") {
|
||||
await markNotificationsAsRead(session.user.id, notificationIds);
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
unreadCount,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("Error updating notifications:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
23
src/app/api/notifications/unread-count/route.js
Normal file
23
src/app/api/notifications/unread-count/route.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getUnreadNotificationCount } from "@/lib/notifications";
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const unreadCount = await getUnreadNotificationCount(session.user.id);
|
||||
|
||||
return NextResponse.json({ unreadCount });
|
||||
} catch (error) {
|
||||
console.error("Error fetching unread notification count:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,45 @@
|
||||
import {
|
||||
updateProjectTaskStatus,
|
||||
deleteProjectTask,
|
||||
updateProjectTask,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// PATCH: Update project task status
|
||||
// PUT: Update project task (general update)
|
||||
async function updateProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const updates = await req.json();
|
||||
|
||||
// Validate that we have at least one field to update
|
||||
const allowedFields = ["priority", "status", "assigned_to", "date_started"];
|
||||
const hasValidFields = Object.keys(updates).some((key) =>
|
||||
allowedFields.includes(key)
|
||||
);
|
||||
|
||||
if (!hasValidFields) {
|
||||
return NextResponse.json(
|
||||
{ error: "No valid fields provided for update" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTask(id, updates, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Update project task status
|
||||
async function updateProjectTaskStatusHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { status } = await req.json();
|
||||
|
||||
if (!status) {
|
||||
@@ -17,7 +49,15 @@ async function updateProjectTaskHandler(req, { params }) {
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTaskStatus(params.id, status, req.user?.id || null);
|
||||
const allowedStatuses = ['not_started', 'pending', 'in_progress', 'completed', 'cancelled'];
|
||||
if (!allowedStatuses.includes(status)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid status. Must be one of: " + allowedStatuses.join(', ') },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateProjectTaskStatus(id, status, req.user?.id || null);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task status:", error);
|
||||
@@ -31,16 +71,19 @@ async function updateProjectTaskHandler(req, { params }) {
|
||||
// DELETE: Delete a project task
|
||||
async function deleteProjectTaskHandler(req, { params }) {
|
||||
try {
|
||||
deleteProjectTask(params.id);
|
||||
const { id } = await params;
|
||||
const result = deleteProjectTask(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error in deleteProjectTaskHandler:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete project task" },
|
||||
{ error: "Failed to delete project task", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const PATCH = withUserAuth(updateProjectTaskHandler);
|
||||
export const PUT = withUserAuth(updateProjectTaskHandler);
|
||||
export const PATCH = withUserAuth(updateProjectTaskStatusHandler);
|
||||
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
||||
|
||||
@@ -47,10 +47,19 @@ async function createProjectTaskHandler(req) {
|
||||
const taskData = {
|
||||
...data,
|
||||
created_by: req.user?.id || null,
|
||||
// If no assigned_to is specified, default to the creator
|
||||
assigned_to: data.assigned_to || req.user?.id || null,
|
||||
};
|
||||
|
||||
// Set assigned_to: if specified, use it; otherwise default to creator only if they're not admin
|
||||
if (data.assigned_to) {
|
||||
taskData.assigned_to = data.assigned_to;
|
||||
} else if (req.user?.id) {
|
||||
// Check if the creator is an admin - if so, don't assign to them
|
||||
const userRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
|
||||
taskData.assigned_to = userRole?.role === 'admin' ? null : req.user.id;
|
||||
} else {
|
||||
taskData.assigned_to = null;
|
||||
}
|
||||
|
||||
const result = createProjectTask(taskData);
|
||||
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
||||
} catch (error) {
|
||||
|
||||
111
src/app/api/projects/[id]/contacts/route.js
Normal file
111
src/app/api/projects/[id]/contacts/route.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getProjectContacts,
|
||||
linkContactToProject,
|
||||
unlinkContactFromProject,
|
||||
setPrimaryContact,
|
||||
} from "@/lib/queries/contacts";
|
||||
import { withAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get all contacts for a project
|
||||
async function getProjectContactsHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error fetching project contacts:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch project contacts" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Link contact to project
|
||||
async function linkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId, relationshipType, isPrimary } = await req.json();
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
linkContactToProject(
|
||||
projectId,
|
||||
contactId,
|
||||
relationshipType || "general",
|
||||
isPrimary || false,
|
||||
userId
|
||||
);
|
||||
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error linking contact to project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to link contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Unlink contact from project
|
||||
async function unlinkContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { searchParams } = new URL(req.url);
|
||||
const contactId = parseInt(searchParams.get("contactId"));
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
unlinkContactFromProject(projectId, contactId);
|
||||
return NextResponse.json({ message: "Contact unlinked successfully" });
|
||||
} catch (error) {
|
||||
console.error("Error unlinking contact from project:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unlink contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH: Set primary contact
|
||||
async function setPrimaryContactHandler(req, { params }) {
|
||||
try {
|
||||
const projectId = parseInt(params.id);
|
||||
const { contactId } = await req.json();
|
||||
|
||||
if (!contactId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Contact ID is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
setPrimaryContact(projectId, contactId);
|
||||
const contacts = getProjectContacts(projectId);
|
||||
return NextResponse.json(contacts);
|
||||
} catch (error) {
|
||||
console.error("Error setting primary contact:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to set primary contact" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withAuth(getProjectContactsHandler);
|
||||
export const POST = withAuth(linkContactHandler);
|
||||
export const DELETE = withAuth(unlinkContactHandler);
|
||||
export const PATCH = withAuth(setPrimaryContactHandler);
|
||||
24
src/app/api/projects/[id]/finish-date-updates/route.js
Normal file
24
src/app/api/projects/[id]/finish-date-updates/route.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Force this API route to use Node.js runtime for database access
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { getFinishDateUpdates } from "@/lib/queries/projects";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
|
||||
async function getFinishDateUpdatesHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const updates = getFinishDateUpdates(parseInt(id));
|
||||
return NextResponse.json(updates);
|
||||
} catch (error) {
|
||||
console.error("Error fetching finish date updates:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch finish date updates" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require authentication
|
||||
export const GET = withReadAuth(getFinishDateUpdatesHandler);
|
||||
@@ -3,9 +3,12 @@ export const runtime = "nodejs";
|
||||
|
||||
import {
|
||||
getProjectById,
|
||||
getProjectWithContract,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
} from "@/lib/queries/projects";
|
||||
import { logFieldChange } from "@/lib/queries/fieldHistory";
|
||||
import { addNoteToProject } from "@/lib/queries/notes";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
@@ -14,13 +17,14 @@ import {
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
import { getUserLanguage, serverT } from "@/lib/serverTranslations";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function getProjectHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
const project = getProjectById(parseInt(id));
|
||||
const project = getProjectWithContract(parseInt(id));
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
@@ -40,15 +44,59 @@ async function getProjectHandler(req, { params }) {
|
||||
}
|
||||
|
||||
async function updateProjectHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const data = await req.json();
|
||||
|
||||
// Get user ID from authenticated request
|
||||
const userId = req.user?.id;
|
||||
|
||||
// Get original project data for audit log
|
||||
// Get original project data for audit log and field tracking
|
||||
const originalProject = getProjectById(parseInt(id));
|
||||
|
||||
if (!originalProject) {
|
||||
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Track field changes for specific fields we want to monitor
|
||||
const fieldsToTrack = ['finish_date', 'project_status', 'assigned_to', 'contract_id', 'wartosc_zlecenia'];
|
||||
|
||||
for (const fieldName of fieldsToTrack) {
|
||||
if (data.hasOwnProperty(fieldName)) {
|
||||
const oldValue = originalProject[fieldName];
|
||||
const newValue = data[fieldName];
|
||||
|
||||
if (oldValue !== newValue) {
|
||||
try {
|
||||
logFieldChange('projects', parseInt(id), fieldName, oldValue, newValue, userId);
|
||||
} catch (error) {
|
||||
console.error(`Failed to log field change for ${fieldName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for project cancellation
|
||||
if (data.project_status === 'cancelled' && originalProject.project_status !== 'cancelled') {
|
||||
const now = new Date();
|
||||
const cancellationDate = now.toLocaleDateString('pl-PL', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
const language = getUserLanguage();
|
||||
const cancellationNote = `${serverT("Project cancelled on", language)} ${cancellationDate}`;
|
||||
|
||||
try {
|
||||
addNoteToProject(parseInt(id), cancellationNote, userId, true); // true for is_system
|
||||
} catch (error) {
|
||||
console.error('Failed to log project cancellation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateProject(parseInt(id), data, userId);
|
||||
|
||||
// Get updated project
|
||||
@@ -69,6 +117,13 @@ async function updateProjectHandler(req, { params }) {
|
||||
);
|
||||
|
||||
return NextResponse.json(updatedProject);
|
||||
} catch (error) {
|
||||
console.error("Error in updateProjectHandler:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProjectHandler(req, { params }) {
|
||||
|
||||
97
src/app/api/projects/export/route.js
Normal file
97
src/app/api/projects/export/route.js
Normal file
@@ -0,0 +1,97 @@
|
||||
// Force this API route to use Node.js runtime for database access and file operations
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import { getAllProjects } from "@/lib/queries/projects";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth } from "@/lib/middleware/auth";
|
||||
import {
|
||||
logApiActionSafe,
|
||||
AUDIT_ACTIONS,
|
||||
RESOURCE_TYPES,
|
||||
} from "@/lib/auditLogSafe.js";
|
||||
|
||||
// Make sure the DB is initialized before queries run
|
||||
initializeDatabase();
|
||||
|
||||
async function exportProjectsHandler(req) {
|
||||
try {
|
||||
// Get all projects
|
||||
const projects = getAllProjects();
|
||||
|
||||
// Group projects by status
|
||||
const groupedProjects = projects.reduce((acc, project) => {
|
||||
const status = project.project_status || 'unknown';
|
||||
if (!acc[status]) {
|
||||
acc[status] = [];
|
||||
}
|
||||
acc[status].push({
|
||||
'Nazwa projektu': project.project_name,
|
||||
'Adres': project.address || '',
|
||||
'Działka': project.plot || '',
|
||||
'WP': project.wp || '',
|
||||
'Data zakończenia': project.finish_date || '',
|
||||
'Przypisany do': project.assigned_to_initial || ''
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Polish status translations for sheet names
|
||||
const statusTranslations = {
|
||||
'registered': 'Zarejestrowany',
|
||||
'in_progress_design': 'W realizacji (projektowanie)',
|
||||
'in_progress_construction': 'W realizacji (budowa)',
|
||||
'fulfilled': 'Zakończony',
|
||||
'cancelled': 'Wycofany',
|
||||
'unknown': 'Nieznany'
|
||||
};
|
||||
|
||||
// Create workbook
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
// Create a sheet for each status
|
||||
Object.keys(groupedProjects).forEach(status => {
|
||||
const sheetName = statusTranslations[status] || status;
|
||||
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
});
|
||||
|
||||
// Generate buffer
|
||||
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
// Generate filename with current date
|
||||
const filename = `eksport_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
// Log the export action
|
||||
await logApiActionSafe(
|
||||
req,
|
||||
AUDIT_ACTIONS.DATA_EXPORT,
|
||||
RESOURCE_TYPES.PROJECT,
|
||||
null,
|
||||
req.auth,
|
||||
{
|
||||
exportType: 'excel',
|
||||
totalProjects: projects.length,
|
||||
statuses: Object.keys(groupedProjects)
|
||||
}
|
||||
);
|
||||
|
||||
// Return the Excel file
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting projects to Excel:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to export projects' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = withReadAuth(exportProjectsHandler);
|
||||
192
src/app/api/reports/upcoming-projects/route.js
Normal file
192
src/app/api/reports/upcoming-projects/route.js
Normal file
@@ -0,0 +1,192 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { getAllProjects } from '@/lib/queries/projects';
|
||||
import { parseISO, isAfter, isBefore, startOfDay, addWeeks, differenceInDays } from 'date-fns';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const today = startOfDay(new Date());
|
||||
const nextMonth = addWeeks(today, 5); // Next 5 weeks
|
||||
|
||||
// Get all projects
|
||||
const allProjects = getAllProjects();
|
||||
|
||||
// Filter for upcoming projects (not fulfilled, not cancelled, have finish dates)
|
||||
const upcomingProjects = allProjects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
|
||||
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateA - dateB;
|
||||
});
|
||||
|
||||
// Filter for overdue projects
|
||||
const overdueProjects = allProjects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
if (project.project_status === 'fulfilled' || project.project_status === 'cancelled') return false;
|
||||
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isBefore(projectDate, today);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateB - dateA; // Most recently overdue first
|
||||
});
|
||||
|
||||
// Create workbook
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'Panel Zarządzania Projektami';
|
||||
workbook.created = new Date();
|
||||
|
||||
// Status translations
|
||||
const statusTranslations = {
|
||||
registered: 'Zarejestrowany',
|
||||
approved: 'Zatwierdzony',
|
||||
pending: 'Oczekujący',
|
||||
in_progress: 'W trakcie',
|
||||
in_progress_design: 'W realizacji (projektowanie)',
|
||||
in_progress_construction: 'W realizacji (realizacja)',
|
||||
fulfilled: 'Zakończony',
|
||||
cancelled: 'Wycofany',
|
||||
};
|
||||
|
||||
// Create Upcoming Projects sheet
|
||||
const upcomingSheet = workbook.addWorksheet('Nadchodzące terminy');
|
||||
|
||||
upcomingSheet.columns = [
|
||||
{ header: 'Nazwa projektu', key: 'name', width: 35 },
|
||||
{ header: 'Klient', key: 'customer', width: 25 },
|
||||
{ header: 'Adres', key: 'address', width: 30 },
|
||||
{ header: 'Działka', key: 'plot', width: 15 },
|
||||
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
|
||||
{ header: 'Dni do terminu', key: 'days_until', width: 15 },
|
||||
{ header: 'Status', key: 'status', width: 25 },
|
||||
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
|
||||
];
|
||||
|
||||
// Style header row
|
||||
upcomingSheet.getRow(1).font = { bold: true };
|
||||
upcomingSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FF4472C4' }
|
||||
};
|
||||
upcomingSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
// Add upcoming projects data
|
||||
upcomingProjects.forEach(project => {
|
||||
const daysUntil = differenceInDays(parseISO(project.finish_date), today);
|
||||
const row = upcomingSheet.addRow({
|
||||
name: project.project_name,
|
||||
customer: project.customer || '',
|
||||
address: project.address || '',
|
||||
plot: project.plot || '',
|
||||
finish_date: project.finish_date,
|
||||
days_until: daysUntil,
|
||||
status: statusTranslations[project.project_status] || project.project_status,
|
||||
assigned_to: project.assigned_to || ''
|
||||
});
|
||||
|
||||
// Color code based on urgency
|
||||
if (daysUntil <= 7) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFE0E0' } // Light red
|
||||
};
|
||||
} else if (daysUntil <= 14) {
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFF4E0' } // Light orange
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Create Overdue Projects sheet
|
||||
if (overdueProjects.length > 0) {
|
||||
const overdueSheet = workbook.addWorksheet('Przeterminowane');
|
||||
|
||||
overdueSheet.columns = [
|
||||
{ header: 'Nazwa projektu', key: 'name', width: 35 },
|
||||
{ header: 'Klient', key: 'customer', width: 25 },
|
||||
{ header: 'Adres', key: 'address', width: 30 },
|
||||
{ header: 'Działka', key: 'plot', width: 15 },
|
||||
{ header: 'Data zakończenia', key: 'finish_date', width: 18 },
|
||||
{ header: 'Dni po terminie', key: 'days_overdue', width: 15 },
|
||||
{ header: 'Status', key: 'status', width: 25 },
|
||||
{ header: 'Odpowiedzialny', key: 'assigned_to', width: 20 }
|
||||
];
|
||||
|
||||
// Style header row
|
||||
overdueSheet.getRow(1).font = { bold: true };
|
||||
overdueSheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE74C3C' }
|
||||
};
|
||||
overdueSheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } };
|
||||
|
||||
// Add overdue projects data
|
||||
overdueProjects.forEach(project => {
|
||||
const daysOverdue = Math.abs(differenceInDays(parseISO(project.finish_date), today));
|
||||
const row = overdueSheet.addRow({
|
||||
name: project.project_name,
|
||||
customer: project.customer || '',
|
||||
address: project.address || '',
|
||||
plot: project.plot || '',
|
||||
finish_date: project.finish_date,
|
||||
days_overdue: daysOverdue,
|
||||
status: statusTranslations[project.project_status] || project.project_status,
|
||||
assigned_to: project.assigned_to || ''
|
||||
});
|
||||
|
||||
// Color code based on severity
|
||||
row.fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFE0E0' } // Light red
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Generate buffer
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
|
||||
// Generate filename with current date
|
||||
const filename = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
// Return response with Excel file
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating upcoming projects report:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate report', details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
src/app/api/task-notes/[id]/route.js
Normal file
48
src/app/api/task-notes/[id]/route.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { deleteNote } from "@/lib/queries/notes";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
import db from "@/lib/db";
|
||||
|
||||
// DELETE: Delete a specific task note
|
||||
async function deleteTaskNoteHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "Note ID is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get note data before deletion for permission checking
|
||||
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
|
||||
|
||||
if (!note) {
|
||||
return NextResponse.json({ error: "Note not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if user has permission to delete this note
|
||||
// Users can delete their own notes, or admins can delete any note
|
||||
const userRole = req.user?.role;
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (userRole !== 'admin' && note.created_by !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized to delete this note" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Don't allow deletion of system notes by regular users
|
||||
if (note.is_system && userRole !== 'admin') {
|
||||
return NextResponse.json({ error: "Cannot delete system notes" }, { status: 403 });
|
||||
}
|
||||
|
||||
deleteNote(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting task note:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete task note" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require user authentication
|
||||
export const DELETE = withUserAuth(deleteTaskNoteHandler);
|
||||
35
src/app/api/task-sets/[id]/apply/route.js
Normal file
35
src/app/api/task-sets/[id]/apply/route.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { applyTaskSetToProject } from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// POST: Apply a task set to a project (bulk create project tasks)
|
||||
async function applyTaskSetHandler(req, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { project_id } = await req.json();
|
||||
|
||||
if (!project_id) {
|
||||
return NextResponse.json(
|
||||
{ error: "project_id is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const createdTaskIds = applyTaskSetToProject(id, project_id, req.user?.id || null);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Task set applied successfully. Created ${createdTaskIds.length} tasks.`,
|
||||
createdTaskIds
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error applying task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to apply task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected route - require authentication
|
||||
export const POST = withUserAuth(applyTaskSetHandler);
|
||||
130
src/app/api/task-sets/[id]/route.js
Normal file
130
src/app/api/task-sets/[id]/route.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
getTaskSetById,
|
||||
updateTaskSet,
|
||||
deleteTaskSet,
|
||||
addTaskTemplateToSet,
|
||||
removeTaskTemplateFromSet,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// GET: Get a specific task set with its templates
|
||||
async function getTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const taskSet = getTaskSetById(id);
|
||||
|
||||
if (!taskSet) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(taskSet);
|
||||
} catch (error) {
|
||||
console.error("Error fetching task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch task set" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT: Update a task set
|
||||
async function updateTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const updates = await req.json();
|
||||
|
||||
// Validate required fields
|
||||
if (updates.name !== undefined && !updates.name.trim()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name cannot be empty" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (updates.task_category !== undefined) {
|
||||
const validTypes = ["design", "construction"];
|
||||
if (!validTypes.includes(updates.task_category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid task_category. Must be one of: design, construction" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle template updates
|
||||
if (updates.templates !== undefined) {
|
||||
// Clear existing templates
|
||||
// Note: This is a simple implementation. In a real app, you might want to handle this more efficiently
|
||||
const currentSet = getTaskSetById(id);
|
||||
if (currentSet) {
|
||||
for (const template of currentSet.templates) {
|
||||
removeTaskTemplateFromSet(id, template.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new templates
|
||||
if (Array.isArray(updates.templates)) {
|
||||
for (let i = 0; i < updates.templates.length; i++) {
|
||||
const template = updates.templates[i];
|
||||
addTaskTemplateToSet(id, template.task_id, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove templates from updates object so it doesn't interfere with task set update
|
||||
delete updates.templates;
|
||||
}
|
||||
|
||||
const result = updateTaskSet(id, updates);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error updating task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Delete a task set
|
||||
async function deleteTaskSetHandler(req, { params }) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = deleteTaskSet(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Task set not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskSetHandler);
|
||||
export const PUT = withUserAuth(updateTaskSetHandler);
|
||||
export const DELETE = withUserAuth(deleteTaskSetHandler);
|
||||
60
src/app/api/task-sets/route.js
Normal file
60
src/app/api/task-sets/route.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
getAllTaskSets,
|
||||
getTaskSetsByProjectType,
|
||||
createTaskSet,
|
||||
} from "@/lib/queries/tasks";
|
||||
import { NextResponse } from "next/server";
|
||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
import initializeDatabase from "@/lib/init-db";
|
||||
|
||||
// GET: Get all task sets or filter by task category
|
||||
async function getTaskSetsHandler(req) {
|
||||
initializeDatabase();
|
||||
const { searchParams } = new URL(req.url);
|
||||
const taskCategory = searchParams.get("task_category");
|
||||
|
||||
if (taskCategory) {
|
||||
const taskSets = getTaskSetsByTaskCategory(taskCategory);
|
||||
return NextResponse.json(taskSets);
|
||||
} else {
|
||||
const taskSets = getAllTaskSets();
|
||||
return NextResponse.json(taskSets);
|
||||
}
|
||||
}
|
||||
|
||||
// POST: Create a new task set
|
||||
async function createTaskSetHandler(req) {
|
||||
initializeDatabase();
|
||||
try {
|
||||
const data = await req.json();
|
||||
|
||||
if (!data.name || !data.task_category) {
|
||||
return NextResponse.json(
|
||||
{ error: "Name and task_category are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate task_category
|
||||
const validTypes = ["design", "construction"];
|
||||
if (!validTypes.includes(data.task_category)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid task_category. Must be one of: design, construction" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const setId = createTaskSet(data);
|
||||
return NextResponse.json({ success: true, id: setId });
|
||||
} catch (error) {
|
||||
console.error("Error creating task set:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create task set", details: error.message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected routes - require authentication
|
||||
export const GET = withReadAuth(getTaskSetsHandler);
|
||||
export const POST = withUserAuth(createTaskSetHandler);
|
||||
@@ -4,10 +4,11 @@ import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||
|
||||
// GET: Get a specific task template
|
||||
async function getTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const template = db
|
||||
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
.get(params.id);
|
||||
.get(id);
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
@@ -27,20 +28,25 @@ async function getTaskHandler(req, { params }) {
|
||||
|
||||
// PUT: Update a task template
|
||||
async function updateTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
const { name, max_wait_days, description, task_category } = await req.json();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (task_category && !['design', 'construction'].includes(task_category)) {
|
||||
return NextResponse.json({ error: "Invalid task_category (must be design or construction)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET name = ?, max_wait_days = ?, description = ?
|
||||
SET name = ?, max_wait_days = ?, description = ?, task_category = ?
|
||||
WHERE task_id = ? AND is_standard = 1`
|
||||
)
|
||||
.run(name, max_wait_days || 0, description || null, params.id);
|
||||
.run(name, max_wait_days || 0, description || null, task_category, id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
@@ -60,10 +66,11 @@ async function updateTaskHandler(req, { params }) {
|
||||
|
||||
// DELETE: Delete a task template
|
||||
async function deleteTaskHandler(req, { params }) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const result = db
|
||||
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||
.run(params.id);
|
||||
.run(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -5,18 +5,22 @@ import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
||||
|
||||
// POST: create new template
|
||||
async function createTaskHandler(req) {
|
||||
const { name, max_wait_days, description } = await req.json();
|
||||
const { name, max_wait_days, description, task_category } = await req.json();
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!task_category || !['design', 'construction'].includes(task_category)) {
|
||||
return NextResponse.json({ error: "Valid task_category is required (design or construction)" }, { status: 400 });
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO tasks (name, max_wait_days, description, is_standard)
|
||||
VALUES (?, ?, ?, 1)
|
||||
INSERT INTO tasks (name, max_wait_days, description, is_standard, task_category)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
`
|
||||
).run(name, max_wait_days || 0, description || null);
|
||||
).run(name, max_wait_days || 0, description || null, task_category);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
154
src/app/api/templates/[templateId]/route.js
Normal file
154
src/app/api/templates/[templateId]/route.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { unlink } from "fs/promises";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const { templateId } = params;
|
||||
const formData = await request.formData();
|
||||
|
||||
const templateName = formData.get("templateName")?.toString().trim();
|
||||
const description = formData.get("description")?.toString().trim();
|
||||
const file = formData.get("file");
|
||||
|
||||
if (!templateName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = db.prepare(`
|
||||
SELECT * FROM docx_templates WHERE template_id = ? AND is_active = 1
|
||||
`).get(templateId);
|
||||
|
||||
if (!existingTemplate) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
let updateData = {
|
||||
template_name: templateName,
|
||||
description: description || null,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// If a new file is provided, handle file replacement
|
||||
if (file && file.size > 0) {
|
||||
// Validate file type
|
||||
if (!file.name.toLowerCase().endsWith('.docx')) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only .docx files are allowed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file size (10MB limit)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return NextResponse.json(
|
||||
{ error: "File size must be less than 10MB" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Delete old file
|
||||
try {
|
||||
const oldFilePath = path.join(process.cwd(), existingTemplate.file_path);
|
||||
await unlink(oldFilePath);
|
||||
} catch (fileError) {
|
||||
console.warn("Could not delete old template file:", fileError);
|
||||
}
|
||||
|
||||
// Save new file
|
||||
const fileExtension = path.extname(file.name);
|
||||
const fileName = `${Date.now()}-${Math.random().toString(36).substring(2)}${fileExtension}`;
|
||||
const filePath = path.join(process.cwd(), "templates", fileName);
|
||||
|
||||
// Ensure templates directory exists
|
||||
const templatesDir = path.join(process.cwd(), "templates");
|
||||
try {
|
||||
await fs.promises.access(templatesDir);
|
||||
} catch {
|
||||
await fs.promises.mkdir(templatesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
|
||||
updateData.file_path = `templates/${fileName}`;
|
||||
updateData.original_filename = file.name;
|
||||
updateData.file_size = file.size;
|
||||
}
|
||||
|
||||
// Update database
|
||||
const updateFields = Object.keys(updateData).map(key => `${key} = ?`).join(', ');
|
||||
const updateValues = Object.values(updateData);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE docx_templates
|
||||
SET ${updateFields}
|
||||
WHERE template_id = ?
|
||||
`).run([...updateValues, templateId]);
|
||||
|
||||
// Get updated template
|
||||
const updatedTemplate = db.prepare(`
|
||||
SELECT * FROM docx_templates WHERE template_id = ?
|
||||
`).get(templateId);
|
||||
|
||||
return NextResponse.json(updatedTemplate);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Template update error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update template" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const { templateId } = params;
|
||||
|
||||
// Get template info
|
||||
const template = db.prepare(`
|
||||
SELECT * FROM docx_templates WHERE template_id = ?
|
||||
`).get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active to 0
|
||||
db.prepare(`
|
||||
UPDATE docx_templates
|
||||
SET is_active = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE template_id = ?
|
||||
`).run(templateId);
|
||||
|
||||
// Optionally delete the file (uncomment if you want hard delete)
|
||||
// try {
|
||||
// const filePath = path.join(process.cwd(), "public", template.file_path);
|
||||
// await unlink(filePath);
|
||||
// } catch (fileError) {
|
||||
// console.warn("Could not delete template file:", fileError);
|
||||
// }
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Template deletion error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to delete template" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
66
src/app/api/templates/download/[filename]/route.js
Normal file
66
src/app/api/templates/download/[filename]/route.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { readFile } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
// Await params (Next.js 15+ requirement)
|
||||
const { filename } = await params;
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json(
|
||||
{ error: "Filename is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get template info from database
|
||||
const template = db.prepare(`
|
||||
SELECT * FROM docx_templates WHERE stored_filename = ? AND is_active = 1
|
||||
`).get(filename);
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
const filePath = path.join(process.cwd(), "templates", filename);
|
||||
if (!existsSync(filePath)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template file not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await readFile(filePath);
|
||||
|
||||
// Encode filename for Content-Disposition header (RFC 5987)
|
||||
// This handles Polish and other special characters
|
||||
const encodedFilename = encodeURIComponent(template.original_filename);
|
||||
|
||||
// Return file with proper headers
|
||||
const response = new NextResponse(fileBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": template.mime_type || "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": `attachment; filename*=UTF-8''${encodedFilename}`,
|
||||
"Content-Length": fileBuffer.length.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Template download error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to download template" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
209
src/app/api/templates/generate/route.js
Normal file
209
src/app/api/templates/generate/route.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import PizZip from "pizzip";
|
||||
import Docxtemplater from "docxtemplater";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
import { formatDate, formatCoordinates } from "@/lib/utils";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { templateId, projectId, customData } = await request.json();
|
||||
|
||||
if (!templateId || !projectId) {
|
||||
return NextResponse.json(
|
||||
{ error: "templateId and projectId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get template
|
||||
const template = db.prepare(`
|
||||
SELECT * FROM docx_templates WHERE template_id = ? AND is_active = 1
|
||||
`).get(templateId);
|
||||
|
||||
if (!template) {
|
||||
return NextResponse.json(
|
||||
{ error: "Template not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get project data
|
||||
const project = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
c.contract_number,
|
||||
c.customer_contract_number,
|
||||
c.customer,
|
||||
c.investor
|
||||
FROM projects p
|
||||
LEFT JOIN contracts c ON p.contract_id = c.contract_id
|
||||
WHERE p.project_id = ?
|
||||
`).get(projectId);
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{ error: "Project not found" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get project contacts
|
||||
const contacts = db.prepare(`
|
||||
SELECT
|
||||
pc.*,
|
||||
ct.name,
|
||||
ct.phone,
|
||||
ct.email,
|
||||
ct.company,
|
||||
ct.contact_type
|
||||
FROM project_contacts pc
|
||||
JOIN contacts ct ON pc.contact_id = ct.contact_id
|
||||
WHERE pc.project_id = ?
|
||||
ORDER BY pc.is_primary DESC, ct.name
|
||||
`).all(projectId);
|
||||
|
||||
// Load template file
|
||||
const templatePath = path.join(process.cwd(), template.file_path);
|
||||
const templateContent = await readFile(templatePath);
|
||||
|
||||
// Load the docx file as a binary
|
||||
const zip = new PizZip(templateContent);
|
||||
|
||||
const doc = new Docxtemplater(zip, {
|
||||
paragraphLoop: true,
|
||||
linebreaks: true,
|
||||
});
|
||||
|
||||
// Prepare data for template
|
||||
const templateData = {
|
||||
// Project basic info
|
||||
project_name: project.project_name || "",
|
||||
project_number: project.project_number || "",
|
||||
address: project.address || "",
|
||||
city: project.city || "",
|
||||
plot: project.plot || "",
|
||||
district: project.district || "",
|
||||
unit: project.unit || "",
|
||||
investment_number: project.investment_number || "",
|
||||
wp: project.wp || "",
|
||||
coordinates: project.coordinates || "",
|
||||
notes: project.notes || "",
|
||||
|
||||
// Processed fields (extracted/transformed data)
|
||||
investment_number_short: project.investment_number ? project.investment_number.split('-').pop() : "",
|
||||
project_number_short: project.project_number ? project.project_number.split('-').pop() : "",
|
||||
project_name_upper: project.project_name ? project.project_name.toUpperCase() : "",
|
||||
project_name_lower: project.project_name ? project.project_name.toLowerCase() : "",
|
||||
city_upper: project.city ? project.city.toUpperCase() : "",
|
||||
customer_upper: project.customer ? project.customer.toUpperCase() : "",
|
||||
|
||||
// Contract info
|
||||
contract_number: project.contract_number || "",
|
||||
customer_contract_number: project.customer_contract_number || "",
|
||||
customer: project.customer || "",
|
||||
investor: project.investor || "",
|
||||
|
||||
// Dates
|
||||
finish_date: project.finish_date ? formatDate(project.finish_date) : "",
|
||||
completion_date: project.completion_date ? formatDate(project.completion_date) : "",
|
||||
today_date: formatDate(new Date()),
|
||||
|
||||
// Project type and status
|
||||
project_type: project.project_type || "",
|
||||
project_status: project.project_status || "",
|
||||
|
||||
// Financial
|
||||
wartosc_zlecenia: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
|
||||
|
||||
// Contacts
|
||||
contacts: contacts.map(contact => ({
|
||||
name: contact.name || "",
|
||||
phone: contact.phone || "",
|
||||
email: contact.email || "",
|
||||
company: contact.company || "",
|
||||
contact_type: contact.contact_type || "",
|
||||
is_primary: contact.is_primary ? "Tak" : "Nie"
|
||||
})),
|
||||
|
||||
// Primary contact
|
||||
primary_contact: contacts.find(c => c.is_primary)?.name || "",
|
||||
primary_contact_phone: contacts.find(c => c.is_primary)?.phone || "",
|
||||
primary_contact_email: contacts.find(c => c.is_primary)?.email || "",
|
||||
|
||||
// Duplicate fields for repeated use (common fields that users might want to repeat)
|
||||
project_name_1: project.project_name || "",
|
||||
project_name_2: project.project_name || "",
|
||||
project_name_3: project.project_name || "",
|
||||
project_number_1: project.project_number || "",
|
||||
project_number_2: project.project_number || "",
|
||||
customer_1: project.customer || "",
|
||||
customer_2: project.customer || "",
|
||||
address_1: project.address || "",
|
||||
address_2: project.address || "",
|
||||
city_1: project.city || "",
|
||||
city_2: project.city || "",
|
||||
wartosc_zlecenia_1: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
|
||||
wartosc_zlecenia_2: project.wartosc_zlecenia ? project.wartosc_zlecenia.toString() : "",
|
||||
};
|
||||
|
||||
// Merge custom data (custom data takes precedence over project data)
|
||||
if (customData && typeof customData === 'object') {
|
||||
Object.assign(templateData, customData);
|
||||
}
|
||||
|
||||
// Set the template variables
|
||||
doc.setData(templateData);
|
||||
|
||||
try {
|
||||
// Render the document
|
||||
doc.render();
|
||||
} catch (error) {
|
||||
console.error("Template rendering error:", error);
|
||||
|
||||
// Check if it's a duplicate tags error
|
||||
if (error.name === 'TemplateError' && error.properties?.id === 'duplicate_open_tag') {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Template contains duplicate placeholders. Each placeholder (like {{project_name}}) can only be used once in the template. Please modify your DOCX template to use unique placeholders or remove duplicates.",
|
||||
details: `Duplicate tag found: ${error.properties?.xtag || 'unknown'}`
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to render template. Please check template syntax and ensure all placeholders are properly formatted." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the generated document
|
||||
const buf = doc.getZip().generate({
|
||||
type: "nodebuffer",
|
||||
compression: "DEFLATE",
|
||||
});
|
||||
|
||||
// Generate filename
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, "-");
|
||||
const sanitizedTemplateName = template.template_name.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
const sanitizedProjectName = project.project_name.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
const filename = `${sanitizedTemplateName}_${sanitizedProjectName}_${timestamp}.docx`;
|
||||
|
||||
// Return the file as a downloadable response
|
||||
return new NextResponse(buf, {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("Template generation error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to generate document" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
133
src/app/api/templates/route.js
Normal file
133
src/app/api/templates/route.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { writeFile, mkdir, unlink } from "fs/promises";
|
||||
import { existsSync } from "fs";
|
||||
import path from "path";
|
||||
import db from "@/lib/db";
|
||||
|
||||
const TEMPLATES_DIR = path.join(process.cwd(), "templates");
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
];
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const templateName = formData.get("templateName");
|
||||
const description = formData.get("description") || "";
|
||||
|
||||
if (!file || !templateName) {
|
||||
return NextResponse.json(
|
||||
{ error: "File and templateName are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate file
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json(
|
||||
{ error: "File size too large (max 10MB)" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only DOCX files are allowed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Create templates directory
|
||||
if (!existsSync(TEMPLATES_DIR)) {
|
||||
await mkdir(TEMPLATES_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const sanitizedOriginalName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const storedFilename = `${timestamp}_${sanitizedOriginalName}`;
|
||||
const filePath = path.join(TEMPLATES_DIR, storedFilename);
|
||||
const relativePath = `templates/${storedFilename}`;
|
||||
|
||||
// Save file
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
// Save to database
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO docx_templates (
|
||||
template_name, description, original_filename, stored_filename,
|
||||
file_path, file_size, mime_type, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
templateName,
|
||||
description,
|
||||
file.name,
|
||||
storedFilename,
|
||||
relativePath,
|
||||
file.size,
|
||||
file.type,
|
||||
null // TODO: Get from session when auth is implemented
|
||||
);
|
||||
|
||||
const newTemplate = {
|
||||
template_id: result.lastInsertRowid,
|
||||
template_name: templateName,
|
||||
description: description,
|
||||
original_filename: file.name,
|
||||
stored_filename: storedFilename,
|
||||
file_path: relativePath,
|
||||
file_size: file.size,
|
||||
mime_type: file.type,
|
||||
is_active: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
return NextResponse.json(newTemplate, { status: 201 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Template upload error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to upload template" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const templates = db.prepare(`
|
||||
SELECT
|
||||
template_id,
|
||||
template_name,
|
||||
description,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
file_path,
|
||||
file_size,
|
||||
mime_type,
|
||||
is_active,
|
||||
created_at,
|
||||
created_by,
|
||||
updated_at
|
||||
FROM docx_templates
|
||||
WHERE is_active = 1
|
||||
ORDER BY created_at DESC
|
||||
`).all();
|
||||
|
||||
return NextResponse.json(templates);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error fetching templates:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch templates" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
|
||||
function SignInContent() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [username, setUsername] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -21,13 +21,13 @@ function SignInContent() {
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password")
|
||||
setError("Invalid username or password")
|
||||
} else {
|
||||
// Successful login
|
||||
router.push(callbackUrl)
|
||||
@@ -45,10 +45,10 @@ function SignInContent() {
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to your account
|
||||
Zaloguj się do swojego konta
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Access the Project Management Panel
|
||||
Dostęp do panelu
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
@@ -60,24 +60,24 @@ function SignInContent() {
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
Email address
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Nazwa użytkownika
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Nazwa użytkownika"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
Hasło
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@@ -105,7 +105,7 @@ function SignInContent() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Signing in...
|
||||
Zaloguj...
|
||||
</span>
|
||||
) : (
|
||||
"Sign in"
|
||||
@@ -113,13 +113,13 @@ function SignInContent() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{/* <div className="text-center">
|
||||
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||
<p className="font-medium">Default Admin Account:</p>
|
||||
<p>Email: admin@localhost</p>
|
||||
<p>Password: admin123456</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
434
src/app/calendar/page.js
Normal file
434
src/app/calendar/page.js
Normal file
@@ -0,0 +1,434 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import {
|
||||
format,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
addDays,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
addMonths,
|
||||
subMonths,
|
||||
parseISO,
|
||||
isAfter,
|
||||
isBefore,
|
||||
startOfDay,
|
||||
addWeeks
|
||||
} from "date-fns";
|
||||
import { pl } from "date-fns/locale";
|
||||
|
||||
const statusColors = {
|
||||
registered: "bg-blue-100 text-blue-800",
|
||||
approved: "bg-green-100 text-green-800",
|
||||
pending: "bg-yellow-100 text-yellow-800",
|
||||
in_progress: "bg-orange-100 text-orange-800",
|
||||
in_progress_design: "bg-purple-100 text-purple-800",
|
||||
in_progress_construction: "bg-indigo-100 text-indigo-800",
|
||||
fulfilled: "bg-gray-100 text-gray-800",
|
||||
cancelled: "bg-red-100 text-red-800",
|
||||
};
|
||||
|
||||
const getStatusTranslation = (status) => {
|
||||
const translations = {
|
||||
registered: "Zarejestrowany",
|
||||
approved: "Zatwierdzony",
|
||||
pending: "Oczekujący",
|
||||
in_progress: "W trakcie",
|
||||
in_progress_design: "W realizacji (projektowanie)",
|
||||
in_progress_construction: "W realizacji (realizacja)",
|
||||
fulfilled: "Zakończony",
|
||||
cancelled: "Wycofany",
|
||||
};
|
||||
return translations[status] || status;
|
||||
};
|
||||
|
||||
export default function ProjectCalendarPage() {
|
||||
const { t } = useTranslation();
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState('month'); // 'month' or 'upcoming'
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/projects")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
// Filter projects that have finish dates and are not fulfilled
|
||||
const projectsWithDates = data.filter(p =>
|
||||
p.finish_date && p.project_status !== 'fulfilled'
|
||||
);
|
||||
setProjects(projectsWithDates);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching projects:", error);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getProjectsForDate = (date) => {
|
||||
return projects.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isSameDay(projectDate, date);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getUpcomingProjects = () => {
|
||||
const today = startOfDay(new Date());
|
||||
const nextMonth = addWeeks(today, 5); // Next 5 weeks
|
||||
|
||||
return projects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isAfter(projectDate, today) && isBefore(projectDate, nextMonth);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateA - dateB;
|
||||
});
|
||||
};
|
||||
|
||||
const getOverdueProjects = () => {
|
||||
const today = startOfDay(new Date());
|
||||
|
||||
return projects
|
||||
.filter(project => {
|
||||
if (!project.finish_date) return false;
|
||||
try {
|
||||
const projectDate = parseISO(project.finish_date);
|
||||
return isBefore(projectDate, today);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dateA = parseISO(a.finish_date);
|
||||
const dateB = parseISO(b.finish_date);
|
||||
return dateB - dateA; // Most recently overdue first
|
||||
});
|
||||
};
|
||||
|
||||
const handleDownloadReport = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const response = await fetch('/api/reports/upcoming-projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to download report');
|
||||
}
|
||||
|
||||
// Get the blob from the response
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nadchodzace_projekty_${new Date().toISOString().split('T')[0]}.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading report:', error);
|
||||
alert('Błąd podczas pobierania raportu');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCalendarGrid = () => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 });
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days = [];
|
||||
let day = calendarStart;
|
||||
|
||||
while (day <= calendarEnd) {
|
||||
days.push(day);
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
|
||||
const weekdays = ['Pon', 'Wt', 'Śr', 'Czw', 'Pt', 'Sob', 'Nie'];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Calendar Header */}
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{format(currentDate, 'LLLL yyyy', { locale: pl })}
|
||||
</h2>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(subMonths(currentDate, 1))}
|
||||
>
|
||||
←
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
>
|
||||
Dziś
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentDate(addMonths(currentDate, 1))}
|
||||
>
|
||||
→
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weekday Headers */}
|
||||
<div className="grid grid-cols-7 border-b border-gray-200">
|
||||
{weekdays.map(weekday => (
|
||||
<div key={weekday} className="p-2 text-sm font-medium text-gray-500 text-center">
|
||||
{weekday}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar Grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day, index) => {
|
||||
const dayProjects = getProjectsForDate(day);
|
||||
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||
const isToday = isSameDay(day, new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`min-h-[120px] p-2 border-r border-b border-gray-100 ${
|
||||
!isCurrentMonth ? 'bg-gray-50' : 'bg-white'
|
||||
} ${isToday ? 'bg-blue-50' : ''}`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-2 ${
|
||||
!isCurrentMonth ? 'text-gray-400' : isToday ? 'text-blue-600' : 'text-gray-900'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</div>
|
||||
|
||||
{dayProjects.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{dayProjects.slice(0, 3).map(project => (
|
||||
<Link
|
||||
key={project.project_id}
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`text-xs p-1 rounded truncate ${
|
||||
statusColors[project.project_status] || statusColors.registered
|
||||
} hover:opacity-80 transition-opacity`}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
{dayProjects.length > 3 && (
|
||||
<div className="relative group">
|
||||
<div className="text-xs text-gray-500 p-1 cursor-pointer">
|
||||
+{dayProjects.length - 3} więcej
|
||||
</div>
|
||||
<div className="absolute left-0 top-full mt-1 bg-white border border-gray-200 rounded shadow-lg p-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto max-w-xs">
|
||||
<div className="space-y-1">
|
||||
{dayProjects.slice(3).map(project => (
|
||||
<Link
|
||||
key={project.project_id}
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="block"
|
||||
>
|
||||
<div className={`text-xs p-1 rounded truncate ${
|
||||
statusColors[project.project_status] || statusColors.registered
|
||||
} hover:opacity-80 transition-opacity`}>
|
||||
{project.project_name}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpcomingView = () => {
|
||||
const upcomingProjects = getUpcomingProjects().filter(project => project.project_status !== 'cancelled');
|
||||
const overdueProjects = getOverdueProjects().filter(project => project.project_status !== 'cancelled');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upcoming Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Nadchodzące terminy ({upcomingProjects.length})
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upcomingProjects.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{upcomingProjects.map(project => {
|
||||
const daysUntilDeadline = Math.ceil((parseISO(project.finish_date) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div key={project.project_id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{project.project_name}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{project.customer && `${project.customer} • `}
|
||||
{project.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatDate(project.finish_date)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
za {daysUntilDeadline} dni
|
||||
</div>
|
||||
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
||||
{getStatusTranslation(project.project_status) || project.project_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Brak nadchodzących projektów w następnych 4 tygodniach
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overdue Projects */}
|
||||
{overdueProjects.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold text-red-600">
|
||||
Projekty przeterminowane ({overdueProjects.length})
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{overdueProjects.map(project => (
|
||||
<div key={project.project_id} className="flex items-center justify-between p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<div className="flex-1">
|
||||
<Link
|
||||
href={`/projects/${project.project_id}`}
|
||||
className="font-medium text-gray-900 hover:text-blue-600"
|
||||
>
|
||||
{project.project_name}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{project.customer && `${project.customer} • `}
|
||||
{project.address}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-medium text-red-600">
|
||||
{formatDate(project.finish_date)}
|
||||
</div>
|
||||
<Badge className={statusColors[project.project_status] || statusColors.registered}>
|
||||
{getStatusTranslation(project.project_status) || project.project_status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Kalendarz projektów"
|
||||
subtitle={`${projects.length} aktywnych projektów z terminami`}
|
||||
>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={viewMode === 'month' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('month')}
|
||||
>
|
||||
Kalendarz
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'upcoming' ? 'primary' : 'outline'}
|
||||
onClick={() => setViewMode('upcoming')}
|
||||
>
|
||||
Lista terminów
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button
|
||||
onClick={handleDownloadReport}
|
||||
disabled={downloading}
|
||||
className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed rounded transition-colors"
|
||||
title={downloading ? 'Pobieranie...' : 'Eksportuj raport nadchodzących projektów do Excel'}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{viewMode === 'month' ? renderCalendarGrid() : renderUpcomingView()}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
610
src/app/contacts/page.js
Normal file
610
src/app/contacts/page.js
Normal file
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ContactForm from "@/components/ContactForm";
|
||||
import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, status } = useSession();
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [filteredContacts, setFilteredContacts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [stats, setStats] = useState(null);
|
||||
const [selectedContact, setSelectedContact] = useState(null);
|
||||
const [contactProjects, setContactProjects] = useState([]);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/auth/signin");
|
||||
}
|
||||
}, [status, router]);
|
||||
|
||||
// Fetch contacts
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
// Filter contacts
|
||||
useEffect(() => {
|
||||
let filtered = contacts;
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
const search = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(contact) =>
|
||||
contact.name?.toLowerCase().includes(search) ||
|
||||
contact.phone?.toLowerCase().includes(search) ||
|
||||
contact.email?.toLowerCase().includes(search) ||
|
||||
contact.company?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter !== "all") {
|
||||
filtered = filtered.filter(
|
||||
(contact) => contact.contact_type === typeFilter
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredContacts(filtered);
|
||||
}, [contacts, searchTerm, typeFilter]);
|
||||
|
||||
async function fetchContacts() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?is_active=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched contacts:', data);
|
||||
setContacts(data);
|
||||
} else {
|
||||
console.error('Failed to fetch contacts, status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contacts:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch("/api/contacts?stats=true");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(contactId) {
|
||||
if (!confirm("Czy na pewno chcesz usunąć ten kontakt?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contactId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting contact:", error);
|
||||
alert("Nie udało się usunąć kontaktu");
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(contact) {
|
||||
setEditingContact(contact);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function handleViewDetails(contact) {
|
||||
setSelectedContact(contact);
|
||||
setLoadingProjects(true);
|
||||
|
||||
try {
|
||||
// Fetch projects linked to this contact
|
||||
const response = await fetch(`/api/contacts/${contact.contact_id}/projects`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setContactProjects(data.projects || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching contact projects:", error);
|
||||
setContactProjects([]);
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDetails() {
|
||||
setSelectedContact(null);
|
||||
setContactProjects([]);
|
||||
}
|
||||
|
||||
function handleFormSave(contact) {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
fetchContacts();
|
||||
fetchStats();
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setShowForm(false);
|
||||
setEditingContact(null);
|
||||
}
|
||||
|
||||
const getContactTypeBadge = (type) => {
|
||||
const types = {
|
||||
project: { label: "Projekt", variant: "primary" },
|
||||
contractor: { label: "Wykonawca", variant: "warning" },
|
||||
office: { label: "Urząd", variant: "info" },
|
||||
supplier: { label: "Dostawca", variant: "success" },
|
||||
other: { label: "Inny", variant: "secondary" },
|
||||
};
|
||||
return types[type] || types.other;
|
||||
};
|
||||
|
||||
if (status === "loading" || loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<div className="text-gray-600">Ładowanie...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showForm) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<ContactForm
|
||||
initialData={editingContact}
|
||||
onSave={handleFormSave}
|
||||
onCancel={handleFormCancel}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader title="Kontakty" description="Zarządzaj kontaktami do projektów i współpracy">
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Dodaj kontakt
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stats.total_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wszystkie</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{stats.project_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Projekty</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{stats.contractor_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Wykonawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{stats.office_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Urzędy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{stats.supplier_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Dostawcy</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">
|
||||
{stats.other_contacts}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">Inne</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Szukaj po nazwie, telefonie, email lub firmie..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="all">Wszystkie typy</option>
|
||||
<option value="project">Projekty</option>
|
||||
<option value="contractor">Wykonawcy</option>
|
||||
<option value="office">Urzędy</option>
|
||||
<option value="supplier">Dostawcy</option>
|
||||
<option value="other">Inne</option>
|
||||
</select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contacts List */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Kontakt
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Firma / Stanowisko
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Telefon
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Akcje
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-4 py-12 text-center text-gray-500">
|
||||
{searchTerm || typeFilter !== "all"
|
||||
? "Nie znaleziono kontaktów"
|
||||
: "Brak kontaktów. Dodaj pierwszy kontakt."}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredContacts.map((contact) => {
|
||||
const typeBadge = getContactTypeBadge(contact.contact_type);
|
||||
return (
|
||||
<tr key={contact.contact_id} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 cursor-pointer" onClick={() => handleViewDetails(contact)}>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm hover:text-blue-600 transition-colors">
|
||||
{contact.name}
|
||||
</h3>
|
||||
<Badge variant={typeBadge.variant} size="sm" className="text-xs">
|
||||
{typeBadge.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{contact.project_count > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
{contact.project_count} {contact.project_count === 1 ? "projekt" : "projektów"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm text-gray-600">
|
||||
{contact.company && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{contact.company}</span>
|
||||
{contact.position && <span className="text-gray-500 ml-1">• {contact.position}</span>}
|
||||
</div>
|
||||
)}
|
||||
{!contact.company && contact.position && (
|
||||
<div>{contact.position}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{contact.phone && (
|
||||
<div className="space-y-1">
|
||||
{(() => {
|
||||
// Handle multiple phones (could be comma-separated or JSON)
|
||||
let phones = [];
|
||||
try {
|
||||
// Try to parse as JSON array first
|
||||
const parsed = JSON.parse(contact.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [contact.phone];
|
||||
} catch {
|
||||
// Fall back to comma-separated string
|
||||
phones = contact.phone.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
const primaryPhone = phones[0];
|
||||
const additionalPhones = phones.slice(1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={`tel:${primaryPhone}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
{primaryPhone}
|
||||
</a>
|
||||
{additionalPhones.length > 0 && (
|
||||
<div className="text-xs text-gray-500 pl-5">
|
||||
{additionalPhones.length === 1 ? (
|
||||
<a
|
||||
href={`tel:${additionalPhones[0]}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{additionalPhones[0]}
|
||||
</a>
|
||||
) : (
|
||||
<span>+{additionalPhones.length} więcej</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{contact.email && (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="truncate max-w-[200px]">{contact.email}</span>
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(contact);
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(contact.contact_id);
|
||||
}}
|
||||
className="px-2 py-1"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Contact Details Modal */}
|
||||
{selectedContact && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={closeDetails}>
|
||||
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="text-2xl">{selectedContact.name}</CardTitle>
|
||||
<div className="mt-2">
|
||||
<Badge variant={getContactTypeBadge(selectedContact.contact_type).variant}>
|
||||
{getContactTypeBadge(selectedContact.contact_type).label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={closeDetails}>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Informacje kontaktowe</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedContact.phone && (() => {
|
||||
let phones = [];
|
||||
try {
|
||||
const parsed = JSON.parse(selectedContact.phone);
|
||||
phones = Array.isArray(parsed) ? parsed : [selectedContact.phone];
|
||||
} catch {
|
||||
phones = selectedContact.phone.split(',').map(p => p.trim()).filter(p => p);
|
||||
}
|
||||
|
||||
return phones.map((phone, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<a href={`tel:${phone}`} className="text-blue-600 hover:underline">
|
||||
{phone}
|
||||
</a>
|
||||
{index === 0 && phones.length > 1 && (
|
||||
<span className="text-xs text-gray-500">(główny)</span>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
{selectedContact.email && (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<a href={`mailto:${selectedContact.email}`} className="text-blue-600 hover:underline">
|
||||
{selectedContact.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{selectedContact.company && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">🏢</span>
|
||||
<span className="text-gray-700">
|
||||
{selectedContact.company}
|
||||
{selectedContact.position && ` • ${selectedContact.position}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!selectedContact.company && selectedContact.position && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">💼</span>
|
||||
<span className="text-gray-700">{selectedContact.position}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedContact.notes && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Notatki</h3>
|
||||
<p className="text-gray-600 text-sm whitespace-pre-wrap bg-gray-50 p-3 rounded">
|
||||
{selectedContact.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Projects */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">
|
||||
Powiązane projekty ({contactProjects.length})
|
||||
</h3>
|
||||
{loadingProjects ? (
|
||||
<div className="text-center py-4 text-gray-500">Ładowanie projektów...</div>
|
||||
) : contactProjects.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">Brak powiązanych projektów</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{contactProjects.map((project) => (
|
||||
<div
|
||||
key={project.project_id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/projects/${project.project_id}`)}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">{project.project_name}</span>
|
||||
{project.is_primary && (
|
||||
<Badge variant="primary" size="sm">Główny kontakt</Badge>
|
||||
)}
|
||||
</div>
|
||||
{project.relationship_type && (
|
||||
<span className="text-xs text-gray-500">{project.relationship_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 pt-4 border-t">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
closeDetails();
|
||||
handleEdit(selectedContact);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edytuj kontakt
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={closeDetails}>
|
||||
Zamknij
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import PageContainer from "@/components/ui/PageContainer";
|
||||
import PageHeader from "@/components/ui/PageHeader";
|
||||
import { LoadingState } from "@/components/ui/States";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import FileUploadModal from "@/components/FileUploadModal";
|
||||
import FileAttachmentsList from "@/components/FileAttachmentsList";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
|
||||
export default function ContractDetailsPage() {
|
||||
const params = useParams();
|
||||
@@ -17,6 +20,9 @@ export default function ContractDetailsPage() {
|
||||
const [contract, setContract] = useState(null);
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [attachments, setAttachments] = useState([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchContractDetails() {
|
||||
@@ -52,10 +58,18 @@ export default function ContractDetailsPage() {
|
||||
fetchContractDetails();
|
||||
}
|
||||
}, [contractId]);
|
||||
const handleFileUploaded = (newFile) => {
|
||||
setAttachments(prev => [newFile, ...prev]);
|
||||
};
|
||||
|
||||
const handleFilesChange = (files) => {
|
||||
setAttachments(files);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<LoadingState message="Loading contract details..." />
|
||||
<LoadingState message={t('contracts.loadingContractDetails')} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -65,9 +79,9 @@ export default function ContractDetailsPage() {
|
||||
<PageContainer>
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<p className="text-red-600 text-lg mb-4">Contract not found.</p>
|
||||
<p className="text-red-600 text-lg mb-4">{t('contracts.contractNotFound')}</p>
|
||||
<Link href="/contracts">
|
||||
<Button variant="primary">Back to Contracts</Button>
|
||||
<Button variant="primary">{t('contracts.backToContracts')}</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -77,8 +91,8 @@ export default function ContractDetailsPage() {
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={`Contract ${contract.contract_number}`}
|
||||
description={contract.contract_name || "Contract Details"}
|
||||
title={`${t('contracts.contract')} ${contract.contract_number}`}
|
||||
description={contract.contract_name || t('contracts.contractInformation')}
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/contracts">
|
||||
@@ -96,7 +110,7 @@ export default function ContractDetailsPage() {
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Back to Contracts
|
||||
{t('contracts.backToContracts')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
@@ -114,7 +128,7 @@ export default function ContractDetailsPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
{t('contracts.addProject')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -127,14 +141,14 @@ export default function ContractDetailsPage() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Contract Information
|
||||
{t('contracts.contractInformation')}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Number
|
||||
{t('contracts.contractNumber')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.contract_number}
|
||||
@@ -143,7 +157,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.contract_name && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Contract Name
|
||||
{t('contracts.contractName')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.contract_name}
|
||||
@@ -153,7 +167,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.customer_contract_number && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Customer Contract Number
|
||||
{t('contracts.customerContractNumber')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.customer_contract_number}
|
||||
@@ -163,7 +177,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.customer && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Customer
|
||||
{t('contracts.customer')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.customer}
|
||||
@@ -173,7 +187,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.investor && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Investor
|
||||
{t('contracts.investor')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{contract.investor}
|
||||
@@ -183,7 +197,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.date_signed && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Date Signed
|
||||
{t('contracts.dateSigned')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{formatDate(contract.date_signed)}
|
||||
@@ -193,7 +207,7 @@ export default function ContractDetailsPage() {
|
||||
{contract.finish_date && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||
Finish Date
|
||||
{t('contracts.finishDate')}
|
||||
</span>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{formatDate(contract.finish_date)}
|
||||
@@ -209,22 +223,22 @@ export default function ContractDetailsPage() {
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Summary</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{t('contracts.summary')}</h2>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Projects Count
|
||||
{t('contracts.projectsCount')}
|
||||
</span>
|
||||
<Badge variant="primary" size="lg">
|
||||
{projects.length} Projects
|
||||
{projects.length} {t('contracts.projects')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{contract.finish_date && (
|
||||
<div className="border-t pt-4">
|
||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||
Contract Status
|
||||
{t('contracts.contractStatus')}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -235,8 +249,8 @@ export default function ContractDetailsPage() {
|
||||
size="md"
|
||||
>
|
||||
{new Date(contract.finish_date) > new Date()
|
||||
? "Active"
|
||||
: "Expired"}
|
||||
? t('contracts.active')
|
||||
: t('contracts.expired')}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
@@ -245,12 +259,50 @@ export default function ContractDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract Documents */}
|
||||
<Card className="mb-8">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
{t('contracts.contractDocuments')} ({attachments.length})
|
||||
</h2>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
{t('contracts.uploadDocument')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FileAttachmentsList
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFilesChange={handleFilesChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Associated Projects */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Associated Projects ({projects.length})
|
||||
{t('contracts.associatedProjects')} ({projects.length})
|
||||
</h2>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -267,7 +319,7 @@ export default function ContractDetailsPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Project
|
||||
{t('contracts.addProject')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -289,13 +341,13 @@ export default function ContractDetailsPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No projects yet
|
||||
{t('contracts.noProjectsYet')}
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Get started by creating your first project for this contract
|
||||
{t('contracts.getStartedMessage')}
|
||||
</p>
|
||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||
<Button variant="primary">Create First Project</Button>
|
||||
<Button variant="primary">{t('contracts.createFirstProject')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -361,22 +413,22 @@ export default function ContractDetailsPage() {
|
||||
size="sm"
|
||||
>
|
||||
{project.project_status === "registered"
|
||||
? "Registered"
|
||||
? t('projectStatus.registered')
|
||||
: project.project_status === "in_progress_design"
|
||||
? "In Progress (Design)"
|
||||
? t('projectStatus.in_progress_design')
|
||||
: project.project_status ===
|
||||
"in_progress_construction"
|
||||
? "In Progress (Construction)"
|
||||
? t('projectStatus.in_progress_construction')
|
||||
: project.project_status === "fulfilled"
|
||||
? "Completed"
|
||||
: "Unknown"}
|
||||
? t('projectStatus.fulfilled')
|
||||
: t('projectStatus.unknown')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/projects/${project.project_id}`}>
|
||||
<Button variant="outline" size="sm">
|
||||
View Details
|
||||
{t('contracts.viewDetails')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@@ -386,6 +438,15 @@ export default function ContractDetailsPage() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Upload Modal */}
|
||||
<FileUploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
entityType="contract"
|
||||
entityId={contractId}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user