Compare commits
170 Commits
988a33788d
...
ui-fix
| Author | SHA1 | Date | |
|---|---|---|---|
| c13a991778 | |||
| cb815177a1 | |||
| 7335d17900 | |||
| b358f5d7b7 | |||
| 84f63c37ce | |||
| d49bea8f15 | |||
| 3d2065d8fb | |||
| 3a382a28c0 | |||
| 6dfb0224ab | |||
| daea67fddb | |||
| e993d02a1b | |||
| 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 | ||
|
|
9c076f537e | ||
|
|
1594889e3b | ||
|
|
50fce2f6ba | ||
|
|
5cd56593eb | ||
|
|
faeb1ca80c | ||
|
|
38b0682d83 | ||
|
|
b1a78bf7a8 | ||
|
|
90875db28b | ||
|
|
294d8343d3 | ||
|
|
81afa09f3a | ||
| 7e67fe96ef |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -45,3 +45,8 @@ next-env.d.ts
|
|||||||
|
|
||||||
# kosz
|
# kosz
|
||||||
/kosz
|
/kosz
|
||||||
|
|
||||||
|
# uploads
|
||||||
|
/public/uploads
|
||||||
|
|
||||||
|
/backups
|
||||||
@@ -1,696 +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
|
|
||||||
|
|
||||||
### ✅ What We Have
|
|
||||||
- **Framework**: Next.js 15 with App Router
|
|
||||||
- **Database**: SQLite with better-sqlite3
|
|
||||||
- **API Routes**: Multiple unprotected endpoints (projects, contracts, tasks, notes)
|
|
||||||
- **UI**: Basic navigation and CRUD interfaces
|
|
||||||
- **Security**: ⚠️ **NONE** - All endpoints are publicly accessible
|
|
||||||
|
|
||||||
### ❌ What's Missing
|
|
||||||
- User authentication system
|
|
||||||
- Session management
|
|
||||||
- Role-based access control
|
|
||||||
- API route protection
|
|
||||||
- Input validation & sanitization
|
|
||||||
- Security middleware
|
|
||||||
|
|
||||||
## 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 Plan
|
|
||||||
|
|
||||||
### Phase 1: Foundation Setup
|
|
||||||
|
|
||||||
#### 1.1 Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install next-auth@beta @auth/better-sqlite3-adapter
|
|
||||||
npm install bcryptjs zod
|
|
||||||
npm install @types/bcryptjs # if using TypeScript
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.2 Environment Configuration
|
|
||||||
|
|
||||||
Create `.env.local`:
|
|
||||||
```env
|
|
||||||
# NextAuth.js Configuration
|
|
||||||
NEXTAUTH_SECRET=your-super-secret-key-here-minimum-32-characters
|
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=./data/database.sqlite
|
|
||||||
|
|
||||||
# Optional: Email configuration for password reset
|
|
||||||
EMAIL_SERVER_HOST=smtp.gmail.com
|
|
||||||
EMAIL_SERVER_PORT=587
|
|
||||||
EMAIL_SERVER_USER=your-email@gmail.com
|
|
||||||
EMAIL_SERVER_PASSWORD=your-app-password
|
|
||||||
EMAIL_FROM=noreply@yourapp.com
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1.3 Database Schema Extension
|
|
||||||
|
|
||||||
Add to `src/lib/init-db.js`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Users table
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
email TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
role TEXT CHECK(role IN ('admin', 'project_manager', 'user', 'read_only')) DEFAULT 'user',
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active INTEGER DEFAULT 1,
|
|
||||||
last_login TEXT,
|
|
||||||
failed_login_attempts INTEGER DEFAULT 0,
|
|
||||||
locked_until TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
-- NextAuth.js required tables
|
|
||||||
CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
userId TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
provider TEXT NOT NULL,
|
|
||||||
providerAccountId TEXT NOT NULL,
|
|
||||||
refresh_token TEXT,
|
|
||||||
access_token TEXT,
|
|
||||||
expires_at INTEGER,
|
|
||||||
token_type TEXT,
|
|
||||||
scope TEXT,
|
|
||||||
id_token TEXT,
|
|
||||||
session_state TEXT,
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
|
||||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
||||||
sessionToken TEXT UNIQUE NOT NULL,
|
|
||||||
userId TEXT NOT NULL,
|
|
||||||
expires TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS verification_tokens (
|
|
||||||
identifier TEXT NOT NULL,
|
|
||||||
token TEXT UNIQUE NOT NULL,
|
|
||||||
expires TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (identifier, token)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Audit log table for security tracking
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
resource_type TEXT,
|
|
||||||
resource_id TEXT,
|
|
||||||
ip_address TEXT,
|
|
||||||
user_agent TEXT,
|
|
||||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
details TEXT,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Create indexes for performance
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(sessionToken);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts(userId);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp ON audit_logs(user_id, timestamp);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Authentication Core
|
|
||||||
|
|
||||||
#### 2.1 NextAuth.js Configuration
|
|
||||||
|
|
||||||
Create `src/lib/auth.js`:
|
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
### Phase 1: Foundation
|
|
||||||
- [ ] Install dependencies
|
|
||||||
- [ ] Create environment configuration
|
|
||||||
- [ ] Extend database schema
|
|
||||||
- [ ] Create initial admin user script
|
|
||||||
|
|
||||||
### Phase 2: Authentication
|
|
||||||
- [ ] Configure NextAuth.js
|
|
||||||
- [ ] Create API route handlers
|
|
||||||
- [ ] Test login/logout functionality
|
|
||||||
|
|
||||||
### Phase 3: Authorization
|
|
||||||
- [ ] Implement API middleware
|
|
||||||
- [ ] Protect existing API routes
|
|
||||||
- [ ] Create client-side route protection
|
|
||||||
|
|
||||||
### Phase 4: User Interface
|
|
||||||
- [ ] Create authentication pages
|
|
||||||
- [ ] Update navigation component
|
|
||||||
- [ ] Build user management interface
|
|
||||||
|
|
||||||
### Phase 5: Security
|
|
||||||
- [ ] Add input validation to all endpoints
|
|
||||||
- [ ] Implement rate limiting
|
|
||||||
- [ ] Add audit logging
|
|
||||||
- [ ] Create security headers middleware
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
### 1. Password Security
|
|
||||||
- Minimum 8 characters
|
|
||||||
- Require special characters, numbers
|
|
||||||
- Hash with bcrypt (cost factor 12+)
|
|
||||||
- Implement password history
|
|
||||||
|
|
||||||
### 2. Session Security
|
|
||||||
- Secure cookies
|
|
||||||
- Session rotation
|
|
||||||
- Timeout handling
|
|
||||||
- Device tracking
|
|
||||||
|
|
||||||
### 3. API Security
|
|
||||||
- Input validation on all endpoints
|
|
||||||
- SQL injection prevention (prepared statements)
|
|
||||||
- XSS protection
|
|
||||||
- CSRF tokens
|
|
||||||
|
|
||||||
### 4. Audit & Monitoring
|
|
||||||
- Log all authentication events
|
|
||||||
- Monitor failed login attempts
|
|
||||||
- Track permission changes
|
|
||||||
- Alert on suspicious activity
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
### 1. Authentication Tests
|
|
||||||
- Valid/invalid login attempts
|
|
||||||
- Password reset functionality
|
|
||||||
- Session expiration
|
|
||||||
- Account lockout
|
|
||||||
|
|
||||||
### 2. Authorization Tests
|
|
||||||
- Role-based access control
|
|
||||||
- API endpoint protection
|
|
||||||
- Resource-level permissions
|
|
||||||
- Privilege escalation attempts
|
|
||||||
|
|
||||||
### 3. Security Tests
|
|
||||||
- SQL injection attempts
|
|
||||||
- XSS attacks
|
|
||||||
- CSRF attacks
|
|
||||||
- Rate limiting
|
|
||||||
|
|
||||||
## Deployment Considerations
|
|
||||||
|
|
||||||
### 1. Environment Variables
|
|
||||||
- Use strong, random secrets
|
|
||||||
- Different keys per environment
|
|
||||||
- Secure secret management
|
|
||||||
|
|
||||||
### 2. Database Security
|
|
||||||
- Regular backups
|
|
||||||
- Encryption at rest
|
|
||||||
- Network security
|
|
||||||
- Access logging
|
|
||||||
|
|
||||||
### 3. Application Security
|
|
||||||
- HTTPS enforcement
|
|
||||||
- Security headers
|
|
||||||
- Content Security Policy
|
|
||||||
- Regular security updates
|
|
||||||
|
|
||||||
## Migration Strategy
|
|
||||||
|
|
||||||
### 1. Development Phase
|
|
||||||
- Implement on development branch
|
|
||||||
- Test thoroughly with sample data
|
|
||||||
- Document all changes
|
|
||||||
|
|
||||||
### 2. Staging Deployment
|
|
||||||
- Deploy to staging environment
|
|
||||||
- Performance testing
|
|
||||||
- Security testing
|
|
||||||
- User acceptance testing
|
|
||||||
|
|
||||||
### 3. Production Deployment
|
|
||||||
- Database backup before migration
|
|
||||||
- Gradual rollout
|
|
||||||
- Monitor for issues
|
|
||||||
- Rollback plan ready
|
|
||||||
|
|
||||||
## Resources and Documentation
|
|
||||||
|
|
||||||
### NextAuth.js
|
|
||||||
- [Official Documentation](https://next-auth.js.org/)
|
|
||||||
- [Better SQLite3 Adapter](https://authjs.dev/reference/adapter/better-sqlite3)
|
|
||||||
|
|
||||||
### Security Libraries
|
|
||||||
- [Zod Validation](https://zod.dev/)
|
|
||||||
- [bcryptjs](https://www.npmjs.com/package/bcryptjs)
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
|
||||||
- [Next.js Security Guidelines](https://nextjs.org/docs/advanced-features/security-headers)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Next Steps**: Choose which phase to implement first and create detailed implementation tickets for development.
|
|
||||||
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
|
# Use Node.js 22.11.0 as the base image
|
||||||
FROM node:22.11.0
|
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
|
# Set the working directory
|
||||||
WORKDIR /app
|
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 ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm install
|
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 . .
|
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 the default Next.js port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the dev server
|
# Use the entrypoint script
|
||||||
CMD ["npm", "run", "dev"]
|
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,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,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
|
**Last Updated**: January 16, 2026
|
||||||
|
**Version**: 0.1.1
|
||||||
This is a solid Next.js-based project management system for construction/engineering projects with the following existing features:
|
**Status**: Production-Ready Foundation
|
||||||
|
|
||||||
### ✅ 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical Missing Features for App
|
## 📊 Current Application Status
|
||||||
|
|
||||||
### 🔐 **1. Authentication & Authorization (HIGH PRIORITY)**
|
**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.
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
### Advanced Features
|
||||||
2. Authorization and role management
|
- ✅ **Document Generation** - DOCX template system with variable substitution
|
||||||
3. Input validation and security
|
- ✅ **GIS Integration** - Leaflet maps with 8 base layers and 6 overlay layers (Polish geoportal)
|
||||||
4. Backup system
|
- ✅ **CardDAV Sync** - Bi-directional contact sync with Radicale
|
||||||
5. Basic testing coverage
|
- ✅ **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
|
### UI/UX
|
||||||
2. Mobile optimization
|
- ✅ **Dark/Light Theme** - User-selectable with system preference detection
|
||||||
3. Notification system
|
- ✅ **Responsive Design** - Mobile-first, optimized for all screen sizes
|
||||||
4. Enhanced project management features
|
- ✅ **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
|
### Testing & Documentation
|
||||||
2. Performance optimization
|
- ✅ **Testing Framework** - Jest, Playwright, Testing Library configured
|
||||||
3. Advanced UI features
|
- ✅ **E2E Tests** - Project workflow tests implemented
|
||||||
4. Documentation
|
- ✅ **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
|
**Status**: Security foundations complete, need additional hardening
|
||||||
- Create user management system
|
**Completed**: ✅ Authentication, Authorization, Audit Logging, Input Validation
|
||||||
- Add login/logout functionality
|
**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**
|
**Estimated Time**: 2 weeks
|
||||||
|
**Impact**: HIGH - Critical for production security
|
||||||
- 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
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 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
|
**Estimated Time**: 3-4 weeks
|
||||||
- **Prisma** - For better database management (optional upgrade from better-sqlite3)
|
**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
|
- **bcryptjs** - Password hashing
|
||||||
- **rate-limiter-flexible** - Rate limiting
|
- **Leaflet** - Maps with Proj4
|
||||||
|
- **Recharts** - Charts (underutilized)
|
||||||
### Reporting
|
- **jsPDF** - PDF generation (underutilized)
|
||||||
|
- **ExcelJS** - Excel export
|
||||||
- **Chart.js** or **Recharts** - Data visualization
|
- **Docxtemplater** - DOCX generation
|
||||||
- **jsPDF** - PDF generation
|
- **date-fns** - Date handling
|
||||||
- **xlsx** - Excel export
|
- **Jest + Playwright** - Testing frameworks
|
||||||
|
|
||||||
### Notifications
|
|
||||||
|
|
||||||
|
### Recommended Additions
|
||||||
|
- **helmet** or custom middleware - Security headers
|
||||||
|
- **rate-limiter-flexible** - API rate limiting
|
||||||
|
- **DOMPurify** - XSS prevention
|
||||||
- **Nodemailer** - Email sending
|
- **Nodemailer** - Email sending
|
||||||
- **Socket.io** - Real-time notifications
|
- **Redis** - Caching layer (optional, for scale)
|
||||||
|
- **Bull/BullMQ** - Background job processing (optional)
|
||||||
### Testing
|
- **Swagger/OpenAPI** - API documentation
|
||||||
|
- **Sentry** - Error tracking (production)
|
||||||
- **MSW** - API mocking for tests
|
- **MSW** - API mocking for tests
|
||||||
- **Testing Library** - Component testing
|
- **Storybook** - Component documentation (optional)
|
||||||
- **Faker.js** - Test data generation
|
|
||||||
|
### 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
|
1. ✅ **Production-Ready Foundation** - Core features complete and tested
|
||||||
2. **Modern tech stack** (Next.js, React, Tailwind)
|
2. ✅ **Comprehensive Security** - Authentication, authorization, audit logging
|
||||||
3. **Good database design** with proper relationships
|
3. ✅ **Well-Structured Codebase** - Clear separation of concerns, modular
|
||||||
4. **Responsive UI** with professional appearance
|
4. ✅ **Modern Tech Stack** - Latest Next.js, React 19, Tailwind CSS
|
||||||
5. **Docker support** for easy deployment
|
5. ✅ **Enterprise Features** - Multi-role system, notifications, file management
|
||||||
6. **Map integration** with multiple layers
|
6. ✅ **Polish Localization** - Full i18n with 1200+ translations
|
||||||
7. **Modular components** that are reusable
|
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
|
### Minimum Production Deployment (Current State)
|
||||||
- **Full-featured Professional App**: 16-20 weeks
|
**Status**: ✅ **READY NOW**
|
||||||
- **Enterprise-grade Application**: 24-30 weeks
|
- 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,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,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);
|
|
||||||
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:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3001:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/backups
|
||||||
|
- ./uploads:/app/public/uploads
|
||||||
|
- ./templates:/app/templates
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- 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
|
||||||
222
docs/LAYER_NOTES.md
Normal file
222
docs/LAYER_NOTES.md
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
# Map Layers - Implementation Notes & Documentation
|
||||||
|
|
||||||
|
Personal notes and official documentation references for each map layer implementation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Base Layers
|
||||||
|
|
||||||
|
### OpenStreetMap
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- OSM - up to zoom 20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🇵🇱 Polish Orthophoto (Standard Resolution)
|
||||||
|
**Status:** ✅ Working (minor issue)
|
||||||
|
**Type:** WMTS
|
||||||
|
**Service:** Polish Geoportal PZGIK/ORTO
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish Ortophoto stantard - ok up to zoom 19
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities WMTS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution?Service=WMTS&Request=GetCapabilities`
|
||||||
|
- GetCapabilities WMS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/StandardResolution?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🇵🇱 Polish Orthophoto (High Resolution)
|
||||||
|
**Status:** Not Working
|
||||||
|
**Type:** WMTS
|
||||||
|
**Service:** Polish Geoportal PZGIK/ORTO
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish Ortophoto Hirez - doesnt load at all
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities WMTS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?Service=WMTS&Request=GetCapabilities`
|
||||||
|
- GetCapabilities WMS: `https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMS/HighResolution?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🌍 Google Satellite
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=s&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Google sat - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🌍 Google Hybrid
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=y&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Google hyb - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Satellite (Esri)
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**Service:** ArcGIS Online World Imagery
|
||||||
|
**URL:** `https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Esri - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Topographic
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles
|
||||||
|
**Service:** CARTO Voyager
|
||||||
|
**URL:** `https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Topo - ok (20)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlay Layers - Polish Government
|
||||||
|
|
||||||
|
### 📋 Polish Cadastral Data (Działki) - Server 1
|
||||||
|
**Status:** - VERY SLOW
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** GUGiK - Krajowa Integracja Ewidencji Gruntów
|
||||||
|
**URL:** `https://integracja01.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow`
|
||||||
|
**Opacity:** 0.8
|
||||||
|
|
||||||
|
**Layers:** `powiaty,powiaty_obreby,zsin,obreby,dzialki,geoportal,numery_dzialek,budynki`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish cadastral data server 1 - very slow, works only up to zoom 18
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://integracja01.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📋 Polish Cadastral Data (Działki) - Server 2
|
||||||
|
**Status:** - VERY SLOW
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** GUGiK - Krajowa Integracja Ewidencji Gruntów
|
||||||
|
**URL:** `https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow`
|
||||||
|
**Opacity:** 0.8
|
||||||
|
|
||||||
|
**Layers:** `dzialki,obreby,numery_dzialek,budynki,kontury,uzytki`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Polish cadastral data server 2 - very slow, works only up to zoom 18 (this is the current official one afaik)
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🏗️ Polish Spatial Planning
|
||||||
|
**Status:** Not Working
|
||||||
|
**Type:** WMS 1.3.0
|
||||||
|
**Service:** Geoportal - Krajowa Integracja Miejscowych Planów Zagospodarowania Przestrzennego
|
||||||
|
**URL:** `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego`
|
||||||
|
**Opacity:** 0.7
|
||||||
|
|
||||||
|
**Layers:** `raster,wektor-str,wektor-lzb,wektor-pow,wektor-lin,wektor-pkt,granice`
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- doesnt seem to work, or is extremely slow
|
||||||
|
|
||||||
|
**Official Documentation:**
|
||||||
|
- GetCapabilities: `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego?Service=WMS&Request=GetCapabilities`
|
||||||
|
-
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overlay Layers - Utility
|
||||||
|
|
||||||
|
### 🌍 Google Roads
|
||||||
|
**Status:** ✅ Working
|
||||||
|
**Type:** XYZ Tiles Overlay
|
||||||
|
**URL:** `http://mt1.google.com/vt/lyrs=h&hl=pl&x={x}&y={y}&z={z}`
|
||||||
|
**Opacity:** 1.0
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Ok
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
### Coordinate Reference Systems
|
||||||
|
- **EPSG:3857** - Web Mercator (current implementation for all layers)
|
||||||
|
- **EPSG:2180** - Polish national projection (PUWG 1992)
|
||||||
|
- Some Polish services support this natively
|
||||||
|
- Would require proj4leaflet for proper support
|
||||||
|
|
||||||
|
### WMS Version Differences
|
||||||
|
- **WMS 1.1.1:** Uses `SRS` parameter for coordinate system
|
||||||
|
- **WMS 1.3.0:** Uses `CRS` parameter for coordinate system
|
||||||
|
- Current implementation auto-detects and handles both
|
||||||
|
|
||||||
|
### Performance Considerations
|
||||||
|
-
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- [ ] Dynamic opacity controls
|
||||||
|
- [ ] Layer legends/metadata panels
|
||||||
|
- [ ] EPSG:2180 support via proj4leaflet
|
||||||
|
- [ ] Layer error handling with fallbacks
|
||||||
|
- [ ] Mobile-optimized layer control
|
||||||
|
|
||||||
|
### Ideas
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References & Resources
|
||||||
|
|
||||||
|
### Polish Geoportal
|
||||||
|
- Main portal: https://www.geoportal.gov.pl/
|
||||||
|
- Service catalog: https://www.geoportal.gov.pl/uslugi
|
||||||
|
-
|
||||||
|
|
||||||
|
### GUGiK (Główny Urząd Geodezji i Kartografii)
|
||||||
|
- Main website: https://www.gugik.gov.pl/
|
||||||
|
-
|
||||||
|
|
||||||
|
### LP-Portal
|
||||||
|
- Website: https://lp-portal.pl/
|
||||||
|
-
|
||||||
|
|
||||||
|
### Leaflet Documentation
|
||||||
|
- WMS Layers: https://leafletjs.com/reference.html#tilelayer-wms
|
||||||
|
-
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 2026-01-16
|
||||||
|
- Created LAYER_NOTES.md for documentation and personal notes
|
||||||
|
- Initial structure with all current layers documented
|
||||||
788
docs/MAP_SYSTEM_UPDATE_PLAN.md
Normal file
788
docs/MAP_SYSTEM_UPDATE_PLAN.md
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
# Map System - Comprehensive Update & Fix Plan
|
||||||
|
|
||||||
|
Based on layer testing results from LAYER_NOTES.md
|
||||||
|
Date: January 16, 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Current Status:**
|
||||||
|
- ✅ **6/7 base layers working** (1 broken: Polish Orthophoto High Resolution)
|
||||||
|
- ⚠️ **2/9 overlay layers working** (2 very slow, 5 not tested, 2 broken)
|
||||||
|
- 🎯 **Priority:** Fix broken layers, optimize slow WMS services, remove LP-Portal layers
|
||||||
|
|
||||||
|
**Key Issues Identified:**
|
||||||
|
1. Polish Orthophoto High Resolution completely broken
|
||||||
|
2. Polish Cadastral Data servers extremely slow (both servers)
|
||||||
|
3. Polish Spatial Planning layer not working
|
||||||
|
4. LP-Portal layers not tested/documented - likely region-specific
|
||||||
|
5. No caching or performance optimization for WMS layers
|
||||||
|
6. Missing zoom level restrictions causing tile request failures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Critical Fixes (Week 1)
|
||||||
|
|
||||||
|
### 1.1 Fix Polish Orthophoto High Resolution
|
||||||
|
**Issue:** Doesn't load at all
|
||||||
|
**Root Cause:** Likely incorrect WMTS parameters or service endpoint change
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Test GetCapabilities response:
|
||||||
|
```bash
|
||||||
|
curl "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?Service=WMTS&Request=GetCapabilities"
|
||||||
|
```
|
||||||
|
2. Compare with Standard Resolution working configuration
|
||||||
|
3. Check for:
|
||||||
|
- Different tile matrix sets
|
||||||
|
- Different available zoom levels
|
||||||
|
- Format differences (jpeg vs png)
|
||||||
|
- Authentication requirements
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Test if service requires different parameters
|
||||||
|
{
|
||||||
|
name: "🇵🇱 Polish Orthophoto (High Resolution)",
|
||||||
|
url: "https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/HighResolution?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=ORTO&STYLE=default&TILEMATRIXSET=EPSG:3857&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&FORMAT=image/jpeg",
|
||||||
|
maxZoom: 19, // May need adjustment based on GetCapabilities
|
||||||
|
minZoom: 15, // High-res often only available at higher zoom
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Layer loads tiles without errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Fix Polish Spatial Planning Layer
|
||||||
|
**Issue:** Doesn't work or extremely slow
|
||||||
|
**Service:** `https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego`
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Verify service is still active via GetCapabilities
|
||||||
|
2. Test with simplified layer list (may be requesting too many layers)
|
||||||
|
3. Check if service moved to new endpoint
|
||||||
|
4. Test with different WMS versions (1.1.1 vs 1.3.0)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Simplified layer request
|
||||||
|
{
|
||||||
|
name: "🏗️ Polish Spatial Planning",
|
||||||
|
type: "wms",
|
||||||
|
url: "https://mapy.geoportal.gov.pl/wss/ext/KrajowaIntegracjaMiejscowychPlanowZagospodarowaniaPrzestrzennego",
|
||||||
|
params: {
|
||||||
|
layers: "raster", // Start with just raster
|
||||||
|
format: "image/png",
|
||||||
|
transparent: true,
|
||||||
|
version: "1.3.0",
|
||||||
|
},
|
||||||
|
maxZoom: 18, // Limit to prevent overload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Layer loads or is removed if permanently unavailable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Optimize Polish Cadastral Data Performance
|
||||||
|
**Issue:** Both servers very slow, currently only work up to zoom 18
|
||||||
|
**Impact:** Core functionality for land surveying projects
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Implement tile loading indicators
|
||||||
|
2. Add request debouncing
|
||||||
|
3. Consider caching strategy
|
||||||
|
4. Test alternate GUGiK services
|
||||||
|
5. (Future) Enable zoom 19-20 with proper optimization
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Update both cadastral servers with performance optimizations
|
||||||
|
{
|
||||||
|
name: "📋 Polish Cadastral Data (Działki) - Server 2",
|
||||||
|
type: "wms",
|
||||||
|
url: "https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow",
|
||||||
|
params: {
|
||||||
|
layers: "dzialki,numery_dzialek,budynki", // Simplified - remove slow layers
|
||||||
|
format: "image/png",
|
||||||
|
transparent: true,
|
||||||
|
version: "1.3.0",
|
||||||
|
},
|
||||||
|
maxZoom: 18, // Current working limit (TODO: extend to 20 with optimization)
|
||||||
|
minZoom: 13, // Don't load at far zoom levels
|
||||||
|
opacity: 0.8,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Additional Optimizations:**
|
||||||
|
- Add WMS tiled parameter: `tiled: true`
|
||||||
|
- Reduce requested layers to essential only
|
||||||
|
- Implement progressive loading (load parcels first, then details)
|
||||||
|
|
||||||
|
**Success Criteria:** Acceptable load times (<3s) at zoom 15-18, prepare for zoom 20 support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Layer Management (Week 2)
|
||||||
|
|
||||||
|
### 2.1 Remove/Document LP-Portal Layers
|
||||||
|
**Issue:** 4 LP-Portal layers never tested, likely region-specific (Nowy Sącz)
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Test if LP-Portal layers work outside Nowy Sącz region
|
||||||
|
2. If region-specific: Move to separate optional configuration
|
||||||
|
3. Document geographic limitations
|
||||||
|
4. Consider conditional loading based on map center coordinates
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
**Option A - Remove Entirely:**
|
||||||
|
```javascript
|
||||||
|
// Remove from mapLayers.overlays array:
|
||||||
|
// - LP-Portal Roads
|
||||||
|
// - LP-Portal Street Names
|
||||||
|
// - LP-Portal Parcels
|
||||||
|
// - LP-Portal Survey Markers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B - Conditional Loading:**
|
||||||
|
```javascript
|
||||||
|
// Only show LP-Portal layers when in Nowy Sącz region
|
||||||
|
const isInNowySecz = (lat, lng) => {
|
||||||
|
return lat >= 49.5 && lat <= 49.7 && lng >= 20.5 && lng <= 20.8;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter overlays based on location
|
||||||
|
const availableOverlays = mapLayers.overlays.filter(layer => {
|
||||||
|
if (layer.name.includes('LP-Portal')) {
|
||||||
|
return isInNowySecz(mapCenter[0], mapCenter[1]);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recommendation:** Option B - Keep but make conditional
|
||||||
|
|
||||||
|
**Success Criteria:** Only relevant layers shown to users
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Reorganize Layer Categories
|
||||||
|
**Current:** Mixed organization, no clear hierarchy
|
||||||
|
**Proposed:** Clear categorization with user-friendly names
|
||||||
|
|
||||||
|
**New Structure:**
|
||||||
|
```javascript
|
||||||
|
export const mapLayers = {
|
||||||
|
base: [
|
||||||
|
// International Base Maps
|
||||||
|
{ name: "OpenStreetMap", ... },
|
||||||
|
{ name: "🌍 Google Satellite", ... },
|
||||||
|
{ name: "🌍 Google Hybrid", ... },
|
||||||
|
{ name: "🗺️ Esri Satellite", ... },
|
||||||
|
{ name: "🗺️ Topographic (CARTO)", ... },
|
||||||
|
|
||||||
|
// Polish Aerial Imagery
|
||||||
|
{ name: "🇵🇱 Orthophoto (Standard)", ... },
|
||||||
|
{ name: "🇵🇱 Orthophoto (High-Res)", ... }, // After fix
|
||||||
|
],
|
||||||
|
|
||||||
|
overlays: {
|
||||||
|
government: [
|
||||||
|
{ name: "📋 Cadastral Data (Official)", ... },
|
||||||
|
{ name: "🏗️ Spatial Planning", ... },
|
||||||
|
],
|
||||||
|
utility: [
|
||||||
|
{ name: "🛣️ Google Roads", ... },
|
||||||
|
],
|
||||||
|
regional: [ // Only shown in specific regions
|
||||||
|
{ name: "🏘️ LP-Portal Roads", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Street Names", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Parcels", region: "nowysacz", ... },
|
||||||
|
{ name: "🏘️ LP-Portal Survey Markers", region: "nowysacz", ... },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Clearer user experience, better organization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Performance Optimization (Week 3)
|
||||||
|
|
||||||
|
### 3.1 Implement Tile Caching
|
||||||
|
**Goal:** Reduce redundant WMS requests
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Add to WMSLayer component
|
||||||
|
const WMSLayer = ({ url, params, opacity, attribution }) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wmsOptions = {
|
||||||
|
// ... existing options ...
|
||||||
|
// Add caching headers
|
||||||
|
crossOrigin: true,
|
||||||
|
updateWhenIdle: true,
|
||||||
|
updateWhenZooming: false,
|
||||||
|
keepBuffer: 2, // Keep tiles loaded from 2 screens away
|
||||||
|
};
|
||||||
|
|
||||||
|
const wmsLayer = L.tileLayer.wms(url, wmsOptions);
|
||||||
|
wmsLayer.addTo(map);
|
||||||
|
|
||||||
|
return () => map.removeLayer(wmsLayer);
|
||||||
|
}, [map, url, params, opacity, attribution]);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** 30% reduction in WMS requests on pan/zoom
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Add Loading States
|
||||||
|
**Goal:** User feedback during slow WMS loads
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// New LoadingOverlay component
|
||||||
|
function MapLoadingOverlay({ isLoading }) {
|
||||||
|
if (!isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-16 right-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg px-4 py-2 z-[1000]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent"></div>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Loading map layers...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track loading state in LeafletMap
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.on('layeradd', () => setIsLoading(true));
|
||||||
|
map.on('load', () => setIsLoading(false));
|
||||||
|
}, [map]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Visual feedback for all layer loads
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 Implement Progressive Layer Loading
|
||||||
|
**Goal:** Load essential layers first, details later
|
||||||
|
|
||||||
|
**Strategy:**
|
||||||
|
1. **Zoom 1-12:** Base map only
|
||||||
|
2. **Zoom 13-15:** + Basic cadastral boundaries
|
||||||
|
3. **Zoom 16-18:** + Parcel numbers, buildings
|
||||||
|
4. **Zoom 19-20:** + Survey markers, detailed overlays
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Auto-enable/disable overlays based on zoom
|
||||||
|
function ZoomBasedOverlayManager() {
|
||||||
|
const map = useMap();
|
||||||
|
const [currentZoom, setCurrentZoom] = useState(map.getZoom());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
map.on('zoomend', () => {
|
||||||
|
const zoom = map.getZoom();
|
||||||
|
setCurrentZoom(zoom);
|
||||||
|
|
||||||
|
// Auto-manage overlay visibility
|
||||||
|
if (zoom < 13) {
|
||||||
|
// Disable all overlays at far zoom
|
||||||
|
disableAllOverlays();
|
||||||
|
} else if (zoom >= 16) {
|
||||||
|
// Enable cadastral at close zoom
|
||||||
|
enableCadastralLayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [map]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Smooth performance at all zoom levels
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Enhanced Features (Week 4)
|
||||||
|
|
||||||
|
### 4.1 Dynamic Opacity Controls
|
||||||
|
**Goal:** User-adjustable layer transparency
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// LayerOpacityControl component
|
||||||
|
function LayerOpacityControl({ layerName, currentOpacity, onOpacityChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<label className="text-xs text-gray-600 dark:text-gray-400 w-24 truncate">
|
||||||
|
{layerName}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={currentOpacity * 100}
|
||||||
|
onChange={(e) => onOpacityChange(e.target.value / 100)}
|
||||||
|
className="flex-1 h-1"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500 w-8 text-right">
|
||||||
|
{Math.round(currentOpacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to layer control
|
||||||
|
<LayersControl position="topright">
|
||||||
|
<Overlay name="Cadastral Data">
|
||||||
|
<WMSLayer {...layer} opacity={cadastralOpacity} />
|
||||||
|
</Overlay>
|
||||||
|
<LayerOpacityControl
|
||||||
|
layerName="Cadastral"
|
||||||
|
currentOpacity={cadastralOpacity}
|
||||||
|
onOpacityChange={setCadastralOpacity}
|
||||||
|
/>
|
||||||
|
</LayersControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** User can adjust opacity for all overlay layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Layer Information Panels
|
||||||
|
**Goal:** Show layer metadata, legends, data source info
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// Layer metadata structure
|
||||||
|
const layerMetadata = {
|
||||||
|
"Polish Cadastral Data": {
|
||||||
|
title: "Polish Cadastral Data (Działki)",
|
||||||
|
description: "Official land parcel boundaries and property information from GUGiK",
|
||||||
|
dataSource: "Główny Urząd Geodezji i Kartografii",
|
||||||
|
updateFrequency: "Daily",
|
||||||
|
coverage: "Poland nationwide",
|
||||||
|
legend: "/images/legends/cadastral.png",
|
||||||
|
moreInfo: "https://www.gugik.gov.pl/",
|
||||||
|
usageNotes: "Best viewed at zoom levels 15-18. Performance may vary.",
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// InfoButton component next to layer name
|
||||||
|
<LayersControl>
|
||||||
|
<Overlay name={
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
📋 Cadastral Data
|
||||||
|
<button onClick={() => showLayerInfo('Polish Cadastral Data')} className="...">
|
||||||
|
ℹ️
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
...
|
||||||
|
</Overlay>
|
||||||
|
</LayersControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Users understand what each layer shows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 Error Handling & Fallbacks
|
||||||
|
**Goal:** Graceful degradation when layers fail
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// WMSLayer with error handling
|
||||||
|
function WMSLayer({ url, params, opacity, attribution, fallbackLayer }) {
|
||||||
|
const map = useMap();
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wmsLayer = L.tileLayer.wms(url, wmsOptions);
|
||||||
|
|
||||||
|
// Track tile errors
|
||||||
|
wmsLayer.on('tileerror', (error) => {
|
||||||
|
console.error(`WMS tile error for ${params.layers}:`, error);
|
||||||
|
setHasError(true);
|
||||||
|
|
||||||
|
// Show user notification
|
||||||
|
showNotification({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Layer "${params.layers}" is experiencing issues`,
|
||||||
|
duration: 5000
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wmsLayer.addTo(map);
|
||||||
|
|
||||||
|
// If too many errors, switch to fallback
|
||||||
|
if (hasError && fallbackLayer) {
|
||||||
|
setTimeout(() => {
|
||||||
|
map.removeLayer(wmsLayer);
|
||||||
|
fallbackLayer.addTo(map);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => map.removeLayer(wmsLayer);
|
||||||
|
}, [map, url, params, hasError]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** No silent failures, users informed of issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Code Quality (Week 5)
|
||||||
|
|
||||||
|
### 5.1 Consolidate Map Components
|
||||||
|
**Current Issue:** Multiple similar map components (ComprehensivePolishMap, ImprovedPolishOrthophotoMap, etc.)
|
||||||
|
|
||||||
|
**Action Plan:**
|
||||||
|
1. Audit all map components:
|
||||||
|
- LeafletMap.js (main)
|
||||||
|
- ProjectMap.js (wrapper)
|
||||||
|
- ComprehensivePolishMap.js
|
||||||
|
- ImprovedPolishOrthophotoMap.js
|
||||||
|
- PolishOrthophotoMap.js
|
||||||
|
- AdvancedPolishOrthophotoMap.js
|
||||||
|
- TransparencyDemoMap.js
|
||||||
|
- CustomWMTSMap.js
|
||||||
|
- EnhancedLeafletMap.js
|
||||||
|
|
||||||
|
2. Determine which are:
|
||||||
|
- Production (keep)
|
||||||
|
- Deprecated (remove)
|
||||||
|
- Experimental (move to /docs/examples)
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
```
|
||||||
|
KEEP:
|
||||||
|
- LeafletMap.js (main production component)
|
||||||
|
- ProjectMap.js (SSR wrapper)
|
||||||
|
|
||||||
|
MOVE TO /docs/examples:
|
||||||
|
- TransparencyDemoMap.js (example of opacity controls)
|
||||||
|
- CustomWMTSMap.js (example of custom WMTS)
|
||||||
|
|
||||||
|
DEPRECATE/REMOVE:
|
||||||
|
- ComprehensivePolishMap.js (superseded by LeafletMap)
|
||||||
|
- ImprovedPolishOrthophotoMap.js (experimental)
|
||||||
|
- PolishOrthophotoMap.js (old version)
|
||||||
|
- AdvancedPolishOrthophotoMap.js (experimental)
|
||||||
|
- EnhancedLeafletMap.js (duplicate?)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Single source of truth for map rendering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Improve WMTS Capabilities Parsing
|
||||||
|
**Current Issue:** wmtsCapabilities.js has placeholder code
|
||||||
|
|
||||||
|
**Options:**
|
||||||
|
|
||||||
|
**Option A - Complete XML Parsing:**
|
||||||
|
```javascript
|
||||||
|
export async function parseWMTSCapabilities(url) {
|
||||||
|
const response = await fetch(`${url}?Service=WMTS&Request=GetCapabilities`);
|
||||||
|
const xmlText = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xml = parser.parseFromString(xmlText, 'text/xml');
|
||||||
|
|
||||||
|
const layers = Array.from(xml.querySelectorAll('Layer')).map(layer => ({
|
||||||
|
id: layer.querySelector('Identifier')?.textContent,
|
||||||
|
title: layer.querySelector('Title')?.textContent,
|
||||||
|
formats: Array.from(layer.querySelectorAll('Format')).map(f => f.textContent),
|
||||||
|
tileMatrixSets: Array.from(layer.querySelectorAll('TileMatrixSet')).map(t => t.textContent),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { layers };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B - Remove and Document:**
|
||||||
|
- Remove wmtsCapabilities.js
|
||||||
|
- Document WMTS configuration in MAP_LAYERS.md
|
||||||
|
- Use manual configuration (current working approach)
|
||||||
|
|
||||||
|
**Recommendation:** Option B - Keep it simple, current approach works
|
||||||
|
|
||||||
|
**Success Criteria:** No dead code, clear documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Add TypeScript/JSDoc Types
|
||||||
|
**Goal:** Better IDE support and type safety
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* @typedef {Object} LayerConfig
|
||||||
|
* @property {string} name - Display name for the layer
|
||||||
|
* @property {'tile'|'wms'} type - Layer type
|
||||||
|
* @property {string} url - Service URL
|
||||||
|
* @property {string} attribution - Attribution HTML
|
||||||
|
* @property {number} [maxZoom=20] - Maximum zoom level
|
||||||
|
* @property {number} [minZoom=0] - Minimum zoom level
|
||||||
|
* @property {number} [opacity=1.0] - Layer opacity (0-1)
|
||||||
|
* @property {boolean} [checked=false] - Default enabled state
|
||||||
|
* @property {Object} [params] - WMS parameters (for WMS layers)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} MapLayersConfig
|
||||||
|
* @property {LayerConfig[]} base - Base layer options
|
||||||
|
* @property {LayerConfig[]} overlays - Overlay layer options
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {MapLayersConfig} */
|
||||||
|
export const mapLayers = {
|
||||||
|
base: [...],
|
||||||
|
overlays: [...]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Better autocomplete and error detection
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Testing & Documentation (Week 6)
|
||||||
|
|
||||||
|
### 6.1 Create Layer Test Suite
|
||||||
|
**Goal:** Automated testing of layer availability
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
```javascript
|
||||||
|
// __tests__/map-layers.test.js
|
||||||
|
describe('Map Layers', () => {
|
||||||
|
describe('Base Layers', () => {
|
||||||
|
test('OSM tiles are accessible', async () => {
|
||||||
|
const response = await fetch('https://a.tile.openstreetmap.org/15/17560/11326.png');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Polish Orthophoto Standard is accessible', async () => {
|
||||||
|
const url = 'https://mapy.geoportal.gov.pl/wss/service/PZGIK/ORTO/WMTS/StandardResolution?Service=WMTS&Request=GetCapabilities';
|
||||||
|
const response = await fetch(url);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WMS Overlays', () => {
|
||||||
|
test('Cadastral WMS GetCapabilities works', async () => {
|
||||||
|
const url = 'https://integracja.gugik.gov.pl/cgi-bin/KrajowaIntegracjaEwidencjiGruntow?Service=WMS&Request=GetCapabilities';
|
||||||
|
const response = await fetch(url);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('content-type')).toContain('xml');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** All layers validated before deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6.2 Update Documentation
|
||||||
|
**Files to Update:**
|
||||||
|
1. MAP_LAYERS.md - Add troubleshooting section
|
||||||
|
2. LAYER_NOTES.md - Keep updated with testing
|
||||||
|
3. README.md - Add maps usage section
|
||||||
|
|
||||||
|
**New Documentation:**
|
||||||
|
```markdown
|
||||||
|
## Troubleshooting Map Layers
|
||||||
|
|
||||||
|
### Slow Loading Cadastral Data
|
||||||
|
- **Cause:** GUGiK WMS servers are resource-limited
|
||||||
|
- **Solution:** Only enable at zoom 15+, limit to essential layers
|
||||||
|
- **Alternative:** Pre-cache frequently used areas
|
||||||
|
|
||||||
|
### Polish Orthophoto Not Loading
|
||||||
|
- **Check:** Zoom level (works up to 19, not 20)
|
||||||
|
- **Check:** Network connectivity to geoportal.gov.pl
|
||||||
|
- **Alternative:** Use Google Satellite or Esri
|
||||||
|
|
||||||
|
### Layer Control Not Showing
|
||||||
|
- **Cause:** Map container too small
|
||||||
|
- **Solution:** Minimum map height of 400px recommended
|
||||||
|
```
|
||||||
|
|
||||||
|
**Success Criteria:** Users can self-service common issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority Matrix
|
||||||
|
|
||||||
|
| Priority | Phase | Task | Impact | Effort | Status |
|
||||||
|
|----------|-------|------|--------|--------|--------|
|
||||||
|
| 🔴 P0 | 1 | Fix Polish Orthophoto High-Res | High | Low | Not Started |
|
||||||
|
| 🔴 P0 | 1 | Add zoom restrictions to Cadastral | High | Low | Not Started |
|
||||||
|
| 🟡 P1 | 1 | Fix/Remove Spatial Planning | Medium | Medium | Not Started |
|
||||||
|
| 🟡 P1 | 2 | Document LP-Portal region limits | Medium | Low | Not Started |
|
||||||
|
| 🟡 P1 | 3 | Add loading indicators | Medium | Low | Not Started |
|
||||||
|
| 🟢 P2 | 2 | Reorganize layer categories | Low | Medium | Not Started |
|
||||||
|
| 🟢 P2 | 4 | Add opacity controls | Low | Medium | Not Started |
|
||||||
|
| 🟢 P2 | 4 | Add layer info panels | Low | High | Not Started |
|
||||||
|
| 🟢 P3 | 5 | Consolidate map components | Low | High | Not Started |
|
||||||
|
| 🟢 P3 | 6 | Add automated tests | Low | Medium | Not Started |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Wins (Do First)
|
||||||
|
|
||||||
|
These can be implemented in 1-2 hours with immediate impact:
|
||||||
|
|
||||||
|
1. **Add minZoom to performance-heavy layers**
|
||||||
|
- Prevent loading at far zoom levels (minZoom: 13 for Cadastral)
|
||||||
|
- Reduce unnecessary requests at distant zoom
|
||||||
|
|
||||||
|
2. **Optimize Cadastral layer requests**
|
||||||
|
- Reduce number of requested WMS layers
|
||||||
|
- Add tiled parameter for better performance
|
||||||
|
|
||||||
|
3. **Remove broken Spatial Planning layer**
|
||||||
|
- If GetCapabilities fails, just remove it
|
||||||
|
- Better than showing broken functionality
|
||||||
|
|
||||||
|
4. **Update layer names for clarity**
|
||||||
|
- "Polish Orthophoto Standard" → "🇵🇱 Aerial Imagery (Standard)"
|
||||||
|
- Better user understanding
|
||||||
|
|
||||||
|
5. **Add loading spinner to ProjectMap**
|
||||||
|
- Copy LoadingOverlay component
|
||||||
|
- Better UX during slow loads
|
||||||
|
|
||||||
|
6. **Verify current zoom limits**
|
||||||
|
- Document actual working zoom ranges per layer
|
||||||
|
- Note: Goal is zoom 20 for all layers (future optimization)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] All base layers load in <2 seconds
|
||||||
|
- [ ] Cadastral overlays load in <5 seconds at zoom 15-18
|
||||||
|
- [ ] No console errors for working layers
|
||||||
|
- [ ] 90%+ tile success rate
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Layer control accessible on all screen sizes
|
||||||
|
- [ ] Clear feedback during loading
|
||||||
|
- [ ] No broken/blank layers in production
|
||||||
|
- [ ] Layer purposes clear from names/descriptions
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- [ ] Single production map component
|
||||||
|
- [ ] All map files under 500 lines
|
||||||
|
- [ ] JSDoc types for all exports
|
||||||
|
- [ ] 80%+ test coverage for layer configs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Plan
|
||||||
|
|
||||||
|
### Week 1: Emergency Fixes
|
||||||
|
- Fix critical broken layers
|
||||||
|
- Add zoom restrictions
|
||||||
|
- Remove non-working layers
|
||||||
|
|
||||||
|
### Week 2: Optimization
|
||||||
|
- Implement caching
|
||||||
|
- Add loading states
|
||||||
|
- Progressive loading
|
||||||
|
|
||||||
|
### Week 3: Features
|
||||||
|
- Opacity controls
|
||||||
|
- Layer info panels
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### Week 4: Cleanup
|
||||||
|
- Consolidate components
|
||||||
|
- Remove experimental code
|
||||||
|
- Update documentation
|
||||||
|
|
||||||
|
### Week 5: Testing
|
||||||
|
- Automated tests
|
||||||
|
- User acceptance testing
|
||||||
|
- Performance benchmarking
|
||||||
|
|
||||||
|
### Week 6: Release
|
||||||
|
- Deploy to production
|
||||||
|
- Monitor performance
|
||||||
|
- Gather user feedback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollback Strategy
|
||||||
|
|
||||||
|
If issues occur:
|
||||||
|
1. **Keep old mapLayers.js** as `mapLayers.backup.js`
|
||||||
|
2. **Feature flags** for new functionality
|
||||||
|
3. **Incremental rollout** - enable for admin users first
|
||||||
|
4. **Quick disable** - config flag to revert to old layers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
### Potential New Features
|
||||||
|
- [ ] **Universal zoom 20 support for all layers**
|
||||||
|
- Optimize WMS services to handle zoom 19-20
|
||||||
|
- Implement tile prefetching and caching
|
||||||
|
- Add progressive detail loading at high zoom
|
||||||
|
- [ ] Save user layer preferences
|
||||||
|
- [ ] Share map view URLs (with layers/zoom)
|
||||||
|
- [ ] Export map as image/PDF
|
||||||
|
- [ ] Offline tile caching
|
||||||
|
- [ ] Custom layer upload (GeoJSON, KML)
|
||||||
|
|
||||||
|
### Alternative Services to Explore
|
||||||
|
- [ ] Planet imagery (if budget allows)
|
||||||
|
- [ ] Bing Maps aerial imagery
|
||||||
|
- [ ] Additional Polish regional services
|
||||||
|
- [ ] CORS proxies for restricted services
|
||||||
|
|
||||||
|
### Advanced Optimizations
|
||||||
|
- [ ] Service worker for tile caching
|
||||||
|
- [ ] WebGL rendering for better performance
|
||||||
|
- [ ] Vector tiles instead of raster
|
||||||
|
- [ ] CDN for frequently accessed tiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- LP-Portal layers appear to be **Nowy Sącz specific** - need regional filtering
|
||||||
|
- Polish government servers are **consistently slow** - can't fix, only mitigate
|
||||||
|
- Google services are **unofficial** - may break without notice
|
||||||
|
- WMTS is more performant than WMS - prefer when available
|
||||||
|
- **Zoom 20 support:** Long-term goal for all layers; currently some layers work only to zoom 18-19
|
||||||
|
- Requires server-side optimization or caching strategy
|
||||||
|
- May need to implement client-side tile scaling/interpolation
|
||||||
|
- Keep LAYER_NOTES.md updated as testing continues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approval & Sign-off
|
||||||
|
|
||||||
|
- [ ] Technical review completed
|
||||||
|
- [ ] Performance benchmarks met
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] Stakeholder approval
|
||||||
|
- [ ] Ready for production deployment
|
||||||
|
|
||||||
|
**Last Updated:** January 16, 2026
|
||||||
|
**Next Review:** After Phase 1 completion
|
||||||
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);
|
||||||
|
});
|
||||||
60
export-projects-to-excel.mjs
Normal file
60
export-projects-to-excel.mjs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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 wpływu': project.start_date || '',
|
||||||
|
'Termin zakończenia': project.finish_date || '',
|
||||||
|
'Data odbioru': project.completion_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();
|
||||||
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!');
|
||||||
3255
package-lock.json
generated
3255
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "panel",
|
"name": "panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,6 +8,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:coverage": "jest --coverage",
|
"test:coverage": "jest --coverage",
|
||||||
@@ -15,18 +19,27 @@
|
|||||||
"test:e2e:ui": "playwright test --ui"
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@mapbox/polyline": "^1.2.1",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"date-fns": "^4.1.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",
|
"leaflet": "^1.9.4",
|
||||||
"next": "15.1.8",
|
"next": "15.1.11",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"pizzip": "^3.2.0",
|
||||||
"proj4": "^2.19.3",
|
"proj4": "^2.19.3",
|
||||||
"proj4leaflet": "^1.0.2",
|
"proj4leaflet": "^1.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"recharts": "^2.15.3",
|
"recharts": "^2.15.3",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37,6 +50,7 @@
|
|||||||
"@testing-library/react": "^16.1.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.0",
|
"@testing-library/user-event": "^14.5.0",
|
||||||
"@types/leaflet": "^1.9.18",
|
"@types/leaflet": "^1.9.18",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.8",
|
"eslint-config-next": "15.1.8",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ async function createInitialAdmin() {
|
|||||||
|
|
||||||
const adminUser = await createUser({
|
const adminUser = await createUser({
|
||||||
name: "Administrator",
|
name: "Administrator",
|
||||||
email: "admin@localhost.com",
|
username: "admin",
|
||||||
password: "admin123456", // Change this in production!
|
password: "admin123456", // Change this in production!
|
||||||
role: "admin"
|
role: "admin"
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log("✅ Initial admin user created successfully!")
|
console.log("✅ Initial admin user created successfully!")
|
||||||
console.log("📧 Email: admin@localhost.com")
|
console.log("<EFBFBD> Username: admin")
|
||||||
console.log("🔑 Password: admin123456")
|
console.log("🔑 Password: admin123456")
|
||||||
console.log("⚠️ Please change the password after first login!")
|
console.log("⚠️ Please change the password after first login!")
|
||||||
console.log("👤 User ID:", adminUser.id)
|
console.log("👤 User ID:", adminUser.id)
|
||||||
|
|||||||
844
scripts/create-comprehensive-test-data.js
Normal file
844
scripts/create-comprehensive-test-data.js
Normal file
@@ -0,0 +1,844 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Comprehensive Test Data Generator
|
||||||
|
*
|
||||||
|
* Creates realistic test data for the panel application including:
|
||||||
|
* - Users with different roles
|
||||||
|
* - Contracts with realistic data
|
||||||
|
* - Projects scattered across Poland with person/company names
|
||||||
|
* - Task templates and sets
|
||||||
|
* - Project tasks with various statuses
|
||||||
|
* - Contacts
|
||||||
|
* - Notes and file attachments
|
||||||
|
* - Notifications and audit logs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import db from '../src/lib/db.js';
|
||||||
|
import initializeDatabase from '../src/lib/init-db.js';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
clearExistingData: true,
|
||||||
|
preserveAdmin: true, // Keep existing admin user
|
||||||
|
seed: 42, // For reproducible random data
|
||||||
|
};
|
||||||
|
|
||||||
|
// Seeded random number generator
|
||||||
|
class SeededRandom {
|
||||||
|
constructor(seed) {
|
||||||
|
this.seed = seed;
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
this.seed = (this.seed * 9301 + 49297) % 233280;
|
||||||
|
return this.seed / 233280;
|
||||||
|
}
|
||||||
|
|
||||||
|
choice(array) {
|
||||||
|
return array[Math.floor(this.next() * array.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
integer(min, max) {
|
||||||
|
return Math.floor(this.next() * (max - min + 1)) + min;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean(probability = 0.5) {
|
||||||
|
return this.next() < probability;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const random = new SeededRandom(CONFIG.seed);
|
||||||
|
|
||||||
|
// Polish cities with coordinates
|
||||||
|
const POLISH_CITIES = [
|
||||||
|
{ name: 'Warszawa', coordinates: '52.2297,21.0122' },
|
||||||
|
{ name: 'Kraków', coordinates: '50.0647,19.9450' },
|
||||||
|
{ name: 'Wrocław', coordinates: '51.1079,17.0385' },
|
||||||
|
{ name: 'Poznań', coordinates: '52.4064,16.9252' },
|
||||||
|
{ name: 'Gdańsk', coordinates: '54.3520,18.6466' },
|
||||||
|
{ name: 'Szczecin', coordinates: '53.4289,14.5530' },
|
||||||
|
{ name: 'Lublin', coordinates: '51.2465,22.5684' },
|
||||||
|
{ name: 'Katowice', coordinates: '50.2649,19.0238' },
|
||||||
|
{ name: 'Łódź', coordinates: '51.7592,19.4600' },
|
||||||
|
{ name: 'Bydgoszcz', coordinates: '53.1235,18.0084' },
|
||||||
|
{ name: 'Białystok', coordinates: '53.1325,23.1688' },
|
||||||
|
{ name: 'Rzeszów', coordinates: '50.0412,21.9991' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Street names
|
||||||
|
const STREET_TYPES = ['ul.', 'al.', 'pl.'];
|
||||||
|
const STREET_NAMES = [
|
||||||
|
'Główna', 'Kwiatowa', 'Słoneczna', 'Przemysłowa', 'Leśna',
|
||||||
|
'Parkowa', 'Centralna', 'Sportowa', 'Polna', 'Krótka',
|
||||||
|
'Długa', 'Nowa', 'Stara', 'Morska', 'Górska', 'Wolności',
|
||||||
|
'Mickiewicza', 'Kościuszki', 'Piłsudskiego', 'Kolejowa'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Project names - people
|
||||||
|
const PERSON_NAMES = [
|
||||||
|
'Jan Kowalski', 'Anna Nowak', 'Piotr Wiśniewski', 'Maria Lewandowska',
|
||||||
|
'Tomasz Kamiński', 'Małgorzata Zielińska', 'Krzysztof Szymański',
|
||||||
|
'Agnieszka Woźniak', 'Andrzej Dąbrowski', 'Barbara Kozłowska',
|
||||||
|
'Józef Jankowski', 'Ewa Wojciechowska', 'Stanisław Kwiatkowski',
|
||||||
|
'Krystyna Kaczmarek', 'Tadeusz Piotrowski'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Project names - companies
|
||||||
|
const COMPANY_NAMES = [
|
||||||
|
'PolBud Sp. z o.o.', 'Constructo Group', 'BuildMaster SA',
|
||||||
|
'EuroDevelopment', 'Invest Property', 'Metropolitan Construction',
|
||||||
|
'Green Building Solutions', 'Nova Inwestycje', 'Prime Estate',
|
||||||
|
'TechBuild Industries', 'Horizon Development', 'Skyline Properties',
|
||||||
|
'Urban Solutions', 'Future Living', 'Capital Investments'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Task templates
|
||||||
|
const DESIGN_TASKS = [
|
||||||
|
{ name: 'Wstępne uzgodnienia z klientem', max_wait_days: 7 },
|
||||||
|
{ name: 'Wizja lokalna i pomiary', max_wait_days: 5 },
|
||||||
|
{ name: 'Projekt koncepcyjny', max_wait_days: 14 },
|
||||||
|
{ name: 'Uzgodnienia projektu koncepcyjnego', max_wait_days: 7 },
|
||||||
|
{ name: 'Projekt budowlany', max_wait_days: 21 },
|
||||||
|
{ name: 'Projekt wykonawczy', max_wait_days: 21 },
|
||||||
|
{ name: 'Specyfikacja techniczna', max_wait_days: 10 },
|
||||||
|
{ name: 'Kosztorys inwestorski', max_wait_days: 7 },
|
||||||
|
{ name: 'Wniosek o pozwolenie na budowę', max_wait_days: 14 },
|
||||||
|
{ name: 'Uzyskanie pozwolenia na budowę', max_wait_days: 60 },
|
||||||
|
{ name: 'Projekt wykonawczy - instalacje', max_wait_days: 21 },
|
||||||
|
{ name: 'Projekt zagospodarowania terenu', max_wait_days: 14 },
|
||||||
|
{ name: 'Dokumentacja powykonawcza', max_wait_days: 14 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSTRUCTION_TASKS = [
|
||||||
|
{ name: 'Przygotowanie placu budowy', max_wait_days: 7 },
|
||||||
|
{ name: 'Wykopy i fundamenty', max_wait_days: 14 },
|
||||||
|
{ name: 'Stan zero', max_wait_days: 21 },
|
||||||
|
{ name: 'Stan surowy otwarty', max_wait_days: 30 },
|
||||||
|
{ name: 'Stan surowy zamknięty', max_wait_days: 30 },
|
||||||
|
{ name: 'Instalacje wewnętrzne', max_wait_days: 21 },
|
||||||
|
{ name: 'Tynki i wylewki', max_wait_days: 14 },
|
||||||
|
{ name: 'Stolarka okienna i drzwiowa', max_wait_days: 10 },
|
||||||
|
{ name: 'Wykończenie - malowanie', max_wait_days: 14 },
|
||||||
|
{ name: 'Wykończenie - podłogi', max_wait_days: 10 },
|
||||||
|
{ name: 'Instalacje sanitarne', max_wait_days: 14 },
|
||||||
|
{ name: 'Instalacje elektryczne', max_wait_days: 14 },
|
||||||
|
{ name: 'Odbiór techniczny', max_wait_days: 7 },
|
||||||
|
{ name: 'Odbiór końcowy', max_wait_days: 7 },
|
||||||
|
{ name: 'Przekazanie dokumentacji', max_wait_days: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Contact types and data
|
||||||
|
const CONTACT_FIRST_NAMES = ['Jan', 'Piotr', 'Anna', 'Maria', 'Tomasz', 'Krzysztof', 'Agnieszka', 'Magdalena', 'Andrzej', 'Ewa'];
|
||||||
|
const CONTACT_LAST_NAMES = ['Kowalski', 'Nowak', 'Wiśniewski', 'Lewandowski', 'Kamiński', 'Zieliński', 'Szymański', 'Woźniak', 'Dąbrowski', 'Kozłowski'];
|
||||||
|
const POSITIONS = ['Kierownik projektu', 'Inżynier', 'Architekt', 'Inspektor nadzoru', 'Przedstawiciel inwestora', 'Dyrektor', 'Koordynator'];
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function generateId() {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateWP() {
|
||||||
|
const part1 = String(random.integer(100000, 999999));
|
||||||
|
const part2 = String(random.integer(1000, 9999));
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const part3 = Array(6).fill(0).map(() => chars[random.integer(0, chars.length - 1)]).join('');
|
||||||
|
return `${part1}/${part2}/${part3}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInvestmentNumber() {
|
||||||
|
const letter = String.fromCharCode(65 + random.integer(0, 25)); // A-Z
|
||||||
|
const letters = String.fromCharCode(65 + random.integer(0, 25)) + String.fromCharCode(65 + random.integer(0, 25));
|
||||||
|
const number = String(random.integer(1000000, 9999999));
|
||||||
|
return `${letter}-${letters}-${number}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDate(startDate, endDate) {
|
||||||
|
const start = new Date(startDate).getTime();
|
||||||
|
const end = new Date(endDate).getTime();
|
||||||
|
const timestamp = start + random.next() * (end - start);
|
||||||
|
return new Date(timestamp).toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateStr, days) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePhoneNumber() {
|
||||||
|
return `${random.integer(500, 799)}-${random.integer(100, 999)}-${random.integer(100, 999)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
function clearData() {
|
||||||
|
console.log('\n🗑️ Clearing existing data...\n');
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
'field_change_history',
|
||||||
|
'notifications',
|
||||||
|
'audit_logs',
|
||||||
|
'file_attachments',
|
||||||
|
'notes',
|
||||||
|
'project_tasks',
|
||||||
|
'task_set_templates',
|
||||||
|
'task_sets',
|
||||||
|
'tasks',
|
||||||
|
'project_contacts',
|
||||||
|
'contacts',
|
||||||
|
'projects',
|
||||||
|
'contracts',
|
||||||
|
'password_reset_tokens',
|
||||||
|
'sessions',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!CONFIG.preserveAdmin) {
|
||||||
|
tables.push('users');
|
||||||
|
}
|
||||||
|
|
||||||
|
tables.forEach(table => {
|
||||||
|
try {
|
||||||
|
db.prepare(`DELETE FROM ${table}`).run();
|
||||||
|
console.log(` ✓ Cleared ${table}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(` ⚠ Warning clearing ${table}:`, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset sequences
|
||||||
|
db.prepare('DELETE FROM sqlite_sequence').run();
|
||||||
|
|
||||||
|
console.log('\n✅ Data cleared successfully\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Create Users
|
||||||
|
function createUsers() {
|
||||||
|
console.log('\n👥 Creating users...\n');
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
const defaultPassword = bcrypt.hashSync('password123', 10);
|
||||||
|
|
||||||
|
// Keep existing admin if preserveAdmin is true
|
||||||
|
if (CONFIG.preserveAdmin) {
|
||||||
|
const existingAdmin = db.prepare('SELECT * FROM users WHERE role = ?').get('admin');
|
||||||
|
if (existingAdmin) {
|
||||||
|
users.push(existingAdmin);
|
||||||
|
console.log(` ✓ Preserved existing admin: ${existingAdmin.username}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUsers = [
|
||||||
|
{ name: 'Maria Kowalska', username: 'maria.kowalska', role: 'team_lead' },
|
||||||
|
{ name: 'Piotr Nowak', username: 'piotr.nowak', role: 'team_lead' },
|
||||||
|
{ name: 'Anna Wiśniewska', username: 'anna.wisniewska', role: 'project_manager' },
|
||||||
|
{ name: 'Tomasz Kamiński', username: 'tomasz.kaminski', role: 'project_manager' },
|
||||||
|
{ name: 'Krzysztof Lewandowski', username: 'krzysztof.lewandowski', role: 'project_manager' },
|
||||||
|
{ name: 'Agnieszka Zielińska', username: 'agnieszka.zielinska', role: 'user' },
|
||||||
|
{ name: 'Marek Szymański', username: 'marek.szymanski', role: 'user' },
|
||||||
|
{ name: 'Ewa Dąbrowska', username: 'ewa.dabrowska', role: 'user' },
|
||||||
|
{ name: 'Janusz Kozłowski', username: 'janusz.kozlowski', role: 'user' },
|
||||||
|
{ name: 'Barbara Wojciechowska', username: 'barbara.wojciechowska', role: 'user' },
|
||||||
|
{ name: 'Viewing Account', username: 'viewer', role: 'read_only' },
|
||||||
|
];
|
||||||
|
|
||||||
|
newUsers.forEach(userData => {
|
||||||
|
const userId = generateId();
|
||||||
|
|
||||||
|
// Generate initials from name
|
||||||
|
const nameParts = userData.name.trim().split(/\s+/);
|
||||||
|
const initial = nameParts.map(part => part.charAt(0).toUpperCase()).join('');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO users (id, name, username, password_hash, role, initial, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(userId, userData.name, userData.username, defaultPassword, userData.role, initial);
|
||||||
|
|
||||||
|
users.push({ id: userId, ...userData });
|
||||||
|
console.log(` ✓ Created ${userData.role}: ${userData.name} (${userData.username})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${newUsers.length} new users (Total: ${users.length})\n`);
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Create Contracts
|
||||||
|
function createContracts() {
|
||||||
|
console.log('\n📄 Creating contracts...\n');
|
||||||
|
|
||||||
|
const contracts = [
|
||||||
|
{
|
||||||
|
number: '2025/FW-001',
|
||||||
|
name: 'Umowa ramowa - projekty mieszkaniowe 2025',
|
||||||
|
customer: 'Deweloper Mieszkaniowy Sp. z o.o.',
|
||||||
|
investor: 'Invest Property Fund',
|
||||||
|
customerContractNumber: 'DMH/2025/001',
|
||||||
|
dateSigned: '2025-01-10',
|
||||||
|
finishDate: '2026-12-31',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '2025/INF-002',
|
||||||
|
name: 'Projekty infrastrukturalne miasta',
|
||||||
|
customer: 'Zarząd Dróg Miejskich',
|
||||||
|
investor: 'Gmina Miasto',
|
||||||
|
customerContractNumber: 'ZDM-2025-02-INF',
|
||||||
|
dateSigned: '2025-02-01',
|
||||||
|
finishDate: '2026-06-30',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '2025/COM-003',
|
||||||
|
name: 'Obiekty komercyjne - centra handlowe',
|
||||||
|
customer: 'Retail Development Group',
|
||||||
|
investor: 'Metropolitan Investments',
|
||||||
|
customerContractNumber: 'RDG/25/COM/03',
|
||||||
|
dateSigned: '2025-01-15',
|
||||||
|
finishDate: '2026-09-30',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const contractIds = [];
|
||||||
|
|
||||||
|
contracts.forEach((contract, index) => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO contracts (
|
||||||
|
contract_number, contract_name, customer_contract_number,
|
||||||
|
customer, investor, date_signed, finish_date
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
contract.number,
|
||||||
|
contract.name,
|
||||||
|
contract.customerContractNumber,
|
||||||
|
contract.customer,
|
||||||
|
contract.investor,
|
||||||
|
contract.dateSigned,
|
||||||
|
contract.finishDate
|
||||||
|
);
|
||||||
|
|
||||||
|
contractIds.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ Created contract: ${contract.number} - ${contract.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${contracts.length} contracts\n`);
|
||||||
|
return contractIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Create Projects
|
||||||
|
function createProjects(contractIds, users) {
|
||||||
|
console.log('\n🏗️ Creating projects...\n');
|
||||||
|
|
||||||
|
const projectCount = random.integer(12, 15);
|
||||||
|
const projects = [];
|
||||||
|
const projectStatuses = ['registered', 'in_progress_design', 'in_progress_construction', 'fulfilled', 'cancelled'];
|
||||||
|
const projectTypes = ['design', 'construction', 'design+construction'];
|
||||||
|
|
||||||
|
const usedCities = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < projectCount; i++) {
|
||||||
|
// Select contract
|
||||||
|
const contractId = random.choice(contractIds);
|
||||||
|
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(contractId);
|
||||||
|
|
||||||
|
// Get sequential number for this contract
|
||||||
|
const existingCount = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(contractId);
|
||||||
|
const sequenceNumber = existingCount.count + 1;
|
||||||
|
const projectNumber = `${sequenceNumber}/${contractInfo.contract_number}`;
|
||||||
|
|
||||||
|
// Select city (try to use different cities)
|
||||||
|
let city;
|
||||||
|
if (usedCities.length < POLISH_CITIES.length) {
|
||||||
|
const availableCities = POLISH_CITIES.filter(c => !usedCities.includes(c.name));
|
||||||
|
city = random.choice(availableCities);
|
||||||
|
usedCities.push(city.name);
|
||||||
|
} else {
|
||||||
|
city = random.choice(POLISH_CITIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate address
|
||||||
|
const streetType = random.choice(STREET_TYPES);
|
||||||
|
const streetName = random.choice(STREET_NAMES);
|
||||||
|
const buildingNumber = random.integer(1, 200);
|
||||||
|
const address = `${streetType} ${streetName} ${buildingNumber}`;
|
||||||
|
|
||||||
|
// Project name (person or company)
|
||||||
|
const projectName = random.boolean(0.6) ? random.choice(PERSON_NAMES) : random.choice(COMPANY_NAMES);
|
||||||
|
|
||||||
|
// Project type and status
|
||||||
|
const projectType = random.choice(projectTypes);
|
||||||
|
const projectStatus = random.choice(projectStatuses);
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
const startDate = generateDate('2025-01-01', '2025-12-31');
|
||||||
|
const finishDate = addDays(startDate, random.integer(90, 365));
|
||||||
|
const completionDate = (projectStatus === 'fulfilled') ? addDays(finishDate, random.integer(-30, 10)) : null;
|
||||||
|
|
||||||
|
// Other fields
|
||||||
|
const wp = generateWP();
|
||||||
|
const investmentNumber = generateInvestmentNumber();
|
||||||
|
const plot = `${random.integer(1, 500)}/${random.integer(1, 50)}`;
|
||||||
|
const district = random.choice(['Centrum', 'Północ', 'Południe', 'Wschód', 'Zachód', 'Śródmieście']);
|
||||||
|
const unit = random.choice(['A', 'B', 'C', 'D', 'E', '1', '2', '3']);
|
||||||
|
|
||||||
|
// Assign to project manager
|
||||||
|
const projectManagers = users.filter(u => u.role === 'project_manager');
|
||||||
|
const assignedTo = random.choice(projectManagers).id;
|
||||||
|
const createdBy = random.choice(users.filter(u => u.role === 'admin' || u.role === 'team_lead')).id;
|
||||||
|
|
||||||
|
const wartoscZlecenia = random.integer(100000, 5000000);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO projects (
|
||||||
|
contract_id, project_name, project_number, address, plot, district, unit, city,
|
||||||
|
investment_number, start_date, finish_date, completion_date, wp,
|
||||||
|
coordinates, project_type, project_status, wartosc_zlecenia,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(
|
||||||
|
contractId, projectName, projectNumber, address, plot, district, unit, city.name,
|
||||||
|
investmentNumber, startDate, finishDate, completionDate, wp,
|
||||||
|
city.coordinates, projectType, projectStatus, wartoscZlecenia,
|
||||||
|
createdBy, assignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
projects.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
name: projectName,
|
||||||
|
number: projectNumber,
|
||||||
|
type: projectType,
|
||||||
|
status: projectStatus,
|
||||||
|
city: city.name,
|
||||||
|
assignedTo: assignedTo,
|
||||||
|
createdBy: createdBy,
|
||||||
|
startDate: startDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${projectNumber}: ${projectName} (${city.name}) - ${projectStatus}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${projects.length} projects\n`);
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Create Task Templates
|
||||||
|
function createTaskTemplates() {
|
||||||
|
console.log('\n✅ Creating task templates...\n');
|
||||||
|
|
||||||
|
const taskIds = { design: [], construction: [] };
|
||||||
|
|
||||||
|
console.log(' Design tasks:');
|
||||||
|
DESIGN_TASKS.forEach(task => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO tasks (name, max_wait_days, is_standard, task_category)
|
||||||
|
VALUES (?, ?, 1, 'design')
|
||||||
|
`).run(task.name, task.max_wait_days);
|
||||||
|
taskIds.design.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ ${task.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\n Construction tasks:');
|
||||||
|
CONSTRUCTION_TASKS.forEach(task => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO tasks (name, max_wait_days, is_standard, task_category)
|
||||||
|
VALUES (?, ?, 1, 'construction')
|
||||||
|
`).run(task.name, task.max_wait_days);
|
||||||
|
taskIds.construction.push(result.lastInsertRowid);
|
||||||
|
console.log(` ✓ ${task.name}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${DESIGN_TASKS.length + CONSTRUCTION_TASKS.length} task templates\n`);
|
||||||
|
return taskIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 5: Create Task Sets
|
||||||
|
function createTaskSets(taskIds) {
|
||||||
|
console.log('\n📋 Creating task sets...\n');
|
||||||
|
|
||||||
|
const sets = [
|
||||||
|
{
|
||||||
|
name: 'Standard - Projektowanie',
|
||||||
|
category: 'design',
|
||||||
|
tasks: taskIds.design.slice(0, 8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pełny zakres - Projektowanie',
|
||||||
|
category: 'design',
|
||||||
|
tasks: taskIds.design,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Standard - Budowa',
|
||||||
|
category: 'construction',
|
||||||
|
tasks: taskIds.construction.slice(0, 10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Pełny zakres - Budowa',
|
||||||
|
category: 'construction',
|
||||||
|
tasks: taskIds.construction,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const setIds = [];
|
||||||
|
|
||||||
|
sets.forEach(set => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO task_sets (name, task_category, created_at, updated_at)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(set.name, set.category);
|
||||||
|
|
||||||
|
const setId = result.lastInsertRowid;
|
||||||
|
setIds.push(setId);
|
||||||
|
|
||||||
|
// Add tasks to set
|
||||||
|
set.tasks.forEach((taskId, index) => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO task_set_templates (set_id, task_template_id, sort_order)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
`).run(setId, taskId, index);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${set.name} (${set.tasks.length} tasks)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${sets.length} task sets\n`);
|
||||||
|
return setIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 6: Create Project Tasks
|
||||||
|
function createProjectTasks(projects, taskIds, users) {
|
||||||
|
console.log('\n📝 Creating project tasks...\n');
|
||||||
|
|
||||||
|
const taskStatuses = ['not_started', 'in_progress', 'completed', 'cancelled'];
|
||||||
|
const priorities = ['normal', 'low', 'high'];
|
||||||
|
let totalTasks = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Select appropriate tasks based on project type
|
||||||
|
let availableTasks = [];
|
||||||
|
if (project.type === 'design') {
|
||||||
|
availableTasks = taskIds.design;
|
||||||
|
} else if (project.type === 'construction') {
|
||||||
|
availableTasks = taskIds.construction;
|
||||||
|
} else {
|
||||||
|
availableTasks = [...taskIds.design, ...taskIds.construction];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 3-7 tasks per project
|
||||||
|
const taskCount = random.integer(3, 7);
|
||||||
|
const selectedTasks = [];
|
||||||
|
|
||||||
|
// Select random tasks
|
||||||
|
for (let i = 0; i < taskCount && selectedTasks.length < availableTasks.length; i++) {
|
||||||
|
let taskId;
|
||||||
|
do {
|
||||||
|
taskId = random.choice(availableTasks);
|
||||||
|
} while (selectedTasks.includes(taskId));
|
||||||
|
selectedTasks.push(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTasks.forEach(taskTemplateId => {
|
||||||
|
// Determine status based on project status
|
||||||
|
let status;
|
||||||
|
if (project.status === 'registered') {
|
||||||
|
status = 'not_started';
|
||||||
|
} else if (project.status === 'fulfilled') {
|
||||||
|
status = 'completed';
|
||||||
|
} else if (project.status === 'cancelled') {
|
||||||
|
status = random.choice(['not_started', 'cancelled']);
|
||||||
|
} else {
|
||||||
|
status = random.choice(taskStatuses.slice(0, 3)); // not_started, in_progress, completed
|
||||||
|
}
|
||||||
|
|
||||||
|
const priority = random.choice(priorities);
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
let dateAdded = project.startDate;
|
||||||
|
let dateStarted = null;
|
||||||
|
let dateCompleted = null;
|
||||||
|
|
||||||
|
if (status === 'in_progress' || status === 'completed') {
|
||||||
|
dateStarted = addDays(dateAdded, random.integer(1, 30));
|
||||||
|
}
|
||||||
|
if (status === 'completed') {
|
||||||
|
dateCompleted = addDays(dateStarted, random.integer(5, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment
|
||||||
|
const regularUsers = users.filter(u => u.role === 'user' || u.role === 'project_manager');
|
||||||
|
const assignedTo = random.boolean(0.7) ? random.choice(regularUsers).id : null;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO project_tasks (
|
||||||
|
project_id, task_template_id, status, priority,
|
||||||
|
date_added, date_started, date_completed,
|
||||||
|
created_by, assigned_to, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(
|
||||||
|
project.id, taskTemplateId, status, priority,
|
||||||
|
dateAdded, dateStarted, dateCompleted,
|
||||||
|
project.createdBy, assignedTo
|
||||||
|
);
|
||||||
|
|
||||||
|
totalTasks++;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ ${project.number}: Created ${taskCount} tasks`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n✅ Created ${totalTasks} project tasks\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 7: Create Contacts
|
||||||
|
function createContacts(users) {
|
||||||
|
console.log('\n👤 Creating contacts...\n');
|
||||||
|
|
||||||
|
const contactTypes = ['project', 'contractor', 'office', 'supplier', 'other'];
|
||||||
|
const contacts = [];
|
||||||
|
const contactCount = random.integer(25, 35);
|
||||||
|
|
||||||
|
for (let i = 0; i < contactCount; i++) {
|
||||||
|
const firstName = random.choice(CONTACT_FIRST_NAMES);
|
||||||
|
const lastName = random.choice(CONTACT_LAST_NAMES);
|
||||||
|
const name = `${firstName} ${lastName}`;
|
||||||
|
const phone = generatePhoneNumber();
|
||||||
|
const email = random.boolean(0.6) ? `${firstName.toLowerCase()}.${lastName.toLowerCase()}@example.com` : null;
|
||||||
|
const company = random.boolean(0.5) ? random.choice(COMPANY_NAMES) : null;
|
||||||
|
const position = random.boolean(0.7) ? random.choice(POSITIONS) : null;
|
||||||
|
const contactType = random.choice(contactTypes);
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO contacts (
|
||||||
|
name, phone, email, company, position, contact_type, is_active,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
`).run(name, phone, email, company, position, contactType);
|
||||||
|
|
||||||
|
contacts.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
name: name,
|
||||||
|
type: contactType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${contacts.length} contacts\n`);
|
||||||
|
return contacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 8: Link Projects to Contacts
|
||||||
|
function linkProjectContacts(projects, contacts, users) {
|
||||||
|
console.log('\n🔗 Linking projects to contacts...\n');
|
||||||
|
|
||||||
|
let linkCount = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Link 1-4 contacts per project
|
||||||
|
const contactsToLink = random.integer(1, 4);
|
||||||
|
const linkedContacts = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < contactsToLink; i++) {
|
||||||
|
let contact;
|
||||||
|
do {
|
||||||
|
contact = random.choice(contacts);
|
||||||
|
} while (linkedContacts.includes(contact.id));
|
||||||
|
|
||||||
|
linkedContacts.push(contact.id);
|
||||||
|
|
||||||
|
const isPrimary = i === 0 ? 1 : 0;
|
||||||
|
const relationshipType = random.choice(['general', 'technical', 'commercial', 'administrative']);
|
||||||
|
const addedBy = random.choice(users).id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO project_contacts (
|
||||||
|
project_id, contact_id, relationship_type, is_primary, added_by, added_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
`).run(project.id, contact.id, relationshipType, isPrimary, addedBy);
|
||||||
|
|
||||||
|
linkCount++;
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore duplicate key errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${linkCount} project-contact links\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 9: Create Notes
|
||||||
|
function createNotes(projects, users) {
|
||||||
|
console.log('\n📝 Creating notes...\n');
|
||||||
|
|
||||||
|
const noteTemplates = [
|
||||||
|
'Spotkanie z klientem - uzgodniono zakres prac',
|
||||||
|
'Wykonano wizję lokalną',
|
||||||
|
'Przesłano dokumentację do uzgodnień',
|
||||||
|
'Otrzymano uwagi do projektu',
|
||||||
|
'Zaktualizowano dokumentację zgodnie z uwagami',
|
||||||
|
'Projekt zatwierdzony przez inwestora',
|
||||||
|
'Rozpoczęto prace na budowie',
|
||||||
|
'Wykonano odbiór częściowy',
|
||||||
|
'Zgłoszono problemy techniczne',
|
||||||
|
'Problem rozwiązany',
|
||||||
|
'Zamówiono materiały',
|
||||||
|
'Dostawa materiałów opóźniona',
|
||||||
|
'Materiały dostarczone na plac budowy',
|
||||||
|
];
|
||||||
|
|
||||||
|
let noteCount = 0;
|
||||||
|
|
||||||
|
projects.forEach(project => {
|
||||||
|
// Create 2-6 notes per project
|
||||||
|
const notesPerProject = random.integer(2, 6);
|
||||||
|
|
||||||
|
for (let i = 0; i < notesPerProject; i++) {
|
||||||
|
const note = random.choice(noteTemplates);
|
||||||
|
const createdBy = random.choice(users).id;
|
||||||
|
const isSystem = random.boolean(0.1) ? 1 : 0;
|
||||||
|
|
||||||
|
// Generate date between project start and now
|
||||||
|
const noteDate = generateDate(project.startDate, '2026-01-26');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO notes (
|
||||||
|
project_id, note, note_date, is_system, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
`).run(project.id, note, noteDate, isSystem, createdBy);
|
||||||
|
|
||||||
|
noteCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${noteCount} notes\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 10: Create Audit Logs
|
||||||
|
function createAuditLogs(users, projects) {
|
||||||
|
console.log('\n📊 Creating audit logs...\n');
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
'user.login',
|
||||||
|
'project.create',
|
||||||
|
'project.update',
|
||||||
|
'project.view',
|
||||||
|
'task.create',
|
||||||
|
'task.update',
|
||||||
|
'task.complete',
|
||||||
|
'file.upload',
|
||||||
|
'contract.create',
|
||||||
|
'contact.create',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ipAddresses = [
|
||||||
|
'192.168.1.100',
|
||||||
|
'192.168.1.101',
|
||||||
|
'10.0.0.50',
|
||||||
|
'172.16.0.10',
|
||||||
|
'83.24.156.78',
|
||||||
|
];
|
||||||
|
|
||||||
|
let logCount = 0;
|
||||||
|
|
||||||
|
// Create 100-200 audit logs
|
||||||
|
const totalLogs = random.integer(100, 200);
|
||||||
|
|
||||||
|
for (let i = 0; i < totalLogs; i++) {
|
||||||
|
const user = random.choice(users);
|
||||||
|
const action = random.choice(actions);
|
||||||
|
const timestamp = generateDate('2025-01-01', '2026-01-26');
|
||||||
|
const ip = random.choice(ipAddresses);
|
||||||
|
|
||||||
|
let resourceType = null;
|
||||||
|
let resourceId = null;
|
||||||
|
|
||||||
|
if (action.includes('project')) {
|
||||||
|
resourceType = 'project';
|
||||||
|
resourceId = String(random.choice(projects).id);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
user_id, action, resource_type, resource_id, ip_address,
|
||||||
|
user_agent, timestamp
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
user.id,
|
||||||
|
action,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ip,
|
||||||
|
'Mozilla/5.0 (compatible)',
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
logCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✓ Created ${logCount} audit logs\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main execution
|
||||||
|
async function main() {
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ Comprehensive Test Data Generator ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize database
|
||||||
|
console.log('\n🔧 Initializing database schema...');
|
||||||
|
initializeDatabase();
|
||||||
|
console.log('✅ Database schema ready\n');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
if (CONFIG.clearExistingData) {
|
||||||
|
clearData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate data in phases
|
||||||
|
const users = createUsers();
|
||||||
|
const contractIds = createContracts();
|
||||||
|
const projects = createProjects(contractIds, users);
|
||||||
|
const taskIds = createTaskTemplates();
|
||||||
|
const taskSetIds = createTaskSets(taskIds);
|
||||||
|
createProjectTasks(projects, taskIds, users);
|
||||||
|
const contacts = createContacts(users);
|
||||||
|
linkProjectContacts(projects, contacts, users);
|
||||||
|
createNotes(projects, users);
|
||||||
|
createAuditLogs(users, projects);
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
console.log('\n╔════════════════════════════════════════════════════════╗');
|
||||||
|
console.log('║ SUMMARY ║');
|
||||||
|
console.log('╚════════════════════════════════════════════════════════╝\n');
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
users: db.prepare('SELECT COUNT(*) as count FROM users').get().count,
|
||||||
|
contracts: db.prepare('SELECT COUNT(*) as count FROM contracts').get().count,
|
||||||
|
projects: db.prepare('SELECT COUNT(*) as count FROM projects').get().count,
|
||||||
|
tasks: db.prepare('SELECT COUNT(*) as count FROM tasks').get().count,
|
||||||
|
taskSets: db.prepare('SELECT COUNT(*) as count FROM task_sets').get().count,
|
||||||
|
projectTasks: db.prepare('SELECT COUNT(*) as count FROM project_tasks').get().count,
|
||||||
|
contacts: db.prepare('SELECT COUNT(*) as count FROM contacts').get().count,
|
||||||
|
projectContacts: db.prepare('SELECT COUNT(*) as count FROM project_contacts').get().count,
|
||||||
|
notes: db.prepare('SELECT COUNT(*) as count FROM notes').get().count,
|
||||||
|
auditLogs: db.prepare('SELECT COUNT(*) as count FROM audit_logs').get().count,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(` 👥 Users: ${stats.users}`);
|
||||||
|
console.log(` 📄 Contracts: ${stats.contracts}`);
|
||||||
|
console.log(` 🏗️ Projects: ${stats.projects}`);
|
||||||
|
console.log(` ✅ Task Templates: ${stats.tasks}`);
|
||||||
|
console.log(` 📋 Task Sets: ${stats.taskSets}`);
|
||||||
|
console.log(` 📝 Project Tasks: ${stats.projectTasks}`);
|
||||||
|
console.log(` 👤 Contacts: ${stats.contacts}`);
|
||||||
|
console.log(` 🔗 Project-Contacts: ${stats.projectContacts}`);
|
||||||
|
console.log(` 📝 Notes: ${stats.notes}`);
|
||||||
|
console.log(` 📊 Audit Logs: ${stats.auditLogs}`);
|
||||||
|
|
||||||
|
console.log('\n✨ Test data generation completed successfully!\n');
|
||||||
|
console.log('💡 Default password for all users: password123\n');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Error:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
226
scripts/create-sample-projects.js
Normal file
226
scripts/create-sample-projects.js
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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',
|
||||||
|
start_date: '2025-01-15',
|
||||||
|
finish_date: '2025-06-30',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2025-02-01',
|
||||||
|
finish_date: '2025-09-15',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2025-01-10',
|
||||||
|
finish_date: '2025-12-20',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2024-11-01',
|
||||||
|
finish_date: '2025-08-10',
|
||||||
|
completion_date: '2025-08-05',
|
||||||
|
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',
|
||||||
|
start_date: '2025-01-20',
|
||||||
|
finish_date: '2025-11-05',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2025-02-10',
|
||||||
|
finish_date: '2025-07-20',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2024-12-15',
|
||||||
|
finish_date: '2025-10-30',
|
||||||
|
completion_date: null,
|
||||||
|
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',
|
||||||
|
start_date: '2024-09-01',
|
||||||
|
finish_date: '2025-05-15',
|
||||||
|
completion_date: '2025-05-12',
|
||||||
|
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',
|
||||||
|
start_date: '2025-01-05',
|
||||||
|
finish_date: '2025-08-25',
|
||||||
|
completion_date: null,
|
||||||
|
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, start_date, finish_date, completion_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.start_date,
|
||||||
|
projectData.finish_date,
|
||||||
|
projectData.completion_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!"
|
||||||
55
src/app/admin/audit-logs/page.js
Normal file
55
src/app/admin/audit-logs/page.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import AuditLogViewer from "@/components/AuditLogViewer";
|
||||||
|
|
||||||
|
export default function AuditLogsPage() {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === "loading") return; // Still loading
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
router.push("/auth/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow admins and project managers to view audit logs
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
router.push("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [session, status, router]);
|
||||||
|
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session || !["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
|
Access Denied
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
You don't have permission to view this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100">
|
||||||
|
<AuditLogViewer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 [user, setUser] = useState(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
username: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
initial: "",
|
||||||
password: ""
|
password: ""
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -62,9 +63,10 @@ export default function EditUserPage() {
|
|||||||
setUser(userData);
|
setUser(userData);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: userData.name,
|
name: userData.name,
|
||||||
email: userData.email,
|
username: userData.username,
|
||||||
role: userData.role,
|
role: userData.role,
|
||||||
is_active: userData.is_active,
|
is_active: userData.is_active,
|
||||||
|
initial: userData.initial || "",
|
||||||
password: "" // Never populate password field
|
password: "" // Never populate password field
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -84,9 +86,10 @@ export default function EditUserPage() {
|
|||||||
// Prepare update data (exclude empty password)
|
// Prepare update data (exclude empty password)
|
||||||
const updateData = {
|
const updateData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
email: formData.email,
|
username: formData.username,
|
||||||
role: formData.role,
|
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
|
// Only include password if it's provided
|
||||||
@@ -209,12 +212,12 @@ export default function EditUserPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Email *
|
Username *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="text"
|
||||||
value={formData.email}
|
value={formData.username}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,6 +235,7 @@ export default function EditUserPage() {
|
|||||||
<option value="read_only">Read Only</option>
|
<option value="read_only">Read Only</option>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="project_manager">Project Manager</option>
|
<option value="project_manager">Project Manager</option>
|
||||||
|
<option value="team_lead">Team Lead</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,6 +257,23 @@ export default function EditUserPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import PageContainer from "@/components/ui/PageContainer";
|
|||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import { LoadingState } from "@/components/ui/States";
|
import { LoadingState } from "@/components/ui/States";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function UserManagementPage() {
|
export default function UserManagementPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -54,7 +56,7 @@ export default function UserManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteUser = async (userId) => {
|
const handleDeleteUser = async (userId) => {
|
||||||
if (!confirm("Are you sure you want to delete this user?")) return;
|
if (!confirm(t('admin.deleteUser') + "?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
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) => {
|
const getRoleColor = (role) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case "admin":
|
case "admin":
|
||||||
return "red";
|
return "red";
|
||||||
|
case "team_lead":
|
||||||
|
return "purple";
|
||||||
case "project_manager":
|
case "project_manager":
|
||||||
return "blue";
|
return "blue";
|
||||||
case "user":
|
case "user":
|
||||||
@@ -114,6 +142,8 @@ export default function UserManagementPage() {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case "project_manager":
|
case "project_manager":
|
||||||
return "Project Manager";
|
return "Project Manager";
|
||||||
|
case "team_lead":
|
||||||
|
return "Team Lead";
|
||||||
case "read_only":
|
case "read_only":
|
||||||
return "Read Only";
|
return "Read Only";
|
||||||
default:
|
default:
|
||||||
@@ -141,7 +171,7 @@ export default function UserManagementPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader title="User Management" description="Manage system users and permissions">
|
<PageHeader title={t('admin.userManagement')} description={t('admin.subtitle')}>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => setShowCreateForm(true)}
|
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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
</svg>
|
</svg>
|
||||||
Add User
|
{t('admin.newUser')}
|
||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -192,7 +222,10 @@ export default function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{user.name}</h3>
|
<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>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -202,6 +235,9 @@ export default function UserManagementPage() {
|
|||||||
<Badge color={user.is_active ? "green" : "red"}>
|
<Badge color={user.is_active ? "green" : "red"}>
|
||||||
{user.is_active ? "Active" : "Inactive"}
|
{user.is_active ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge color={user.can_be_assigned ? "blue" : "gray"}>
|
||||||
|
{user.can_be_assigned ? "Assignable" : "Not Assignable"}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -232,6 +268,20 @@ export default function UserManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -282,10 +332,11 @@ export default function UserManagementPage() {
|
|||||||
function CreateUserModal({ onClose, onUserCreated }) {
|
function CreateUserModal({ onClose, onUserCreated }) {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
email: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
role: "user",
|
role: "user",
|
||||||
is_active: true
|
is_active: true,
|
||||||
|
can_be_assigned: true
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -351,12 +402,12 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email
|
Username
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="text"
|
||||||
value={formData.email}
|
value={formData.username}
|
||||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,6 +437,7 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
|||||||
<option value="read_only">Read Only</option>
|
<option value="read_only">Read Only</option>
|
||||||
<option value="user">User</option>
|
<option value="user">User</option>
|
||||||
<option value="project_manager">Project Manager</option>
|
<option value="project_manager">Project Manager</option>
|
||||||
|
<option value="team_lead">Team Lead</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -403,6 +455,19 @@ function CreateUserModal({ onClose, onUserCreated }) {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button type="submit" disabled={loading} className="flex-1">
|
<Button type="submit" disabled={loading} className="flex-1">
|
||||||
{loading ? "Creating..." : "Create User"}
|
{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)
|
// GET: Get user by ID (admin only)
|
||||||
async function getUserHandler(req, { params }) {
|
async function getUserHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const user = getUserById(params.id);
|
const user = getUserById(id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -29,9 +30,10 @@ async function getUserHandler(req, { params }) {
|
|||||||
|
|
||||||
// PUT: Update user (admin only)
|
// PUT: Update user (admin only)
|
||||||
async function updateUserHandler(req, { params }) {
|
async function updateUserHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
const userId = params.id;
|
const userId = id;
|
||||||
|
|
||||||
// Prevent admin from deactivating themselves
|
// Prevent admin from deactivating themselves
|
||||||
if (data.is_active === false && userId === req.user.id) {
|
if (data.is_active === false && userId === req.user.id) {
|
||||||
@@ -43,7 +45,7 @@ async function updateUserHandler(req, { params }) {
|
|||||||
|
|
||||||
// Validate role if provided
|
// Validate role if provided
|
||||||
if (data.role) {
|
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)) {
|
if (!validRoles.includes(data.role)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid role specified" },
|
{ error: "Invalid role specified" },
|
||||||
@@ -78,7 +80,7 @@ async function updateUserHandler(req, { params }) {
|
|||||||
|
|
||||||
if (error.message.includes("already exists")) {
|
if (error.message.includes("already exists")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "A user with this email already exists" },
|
{ error: "A user with this username already exists" },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,8 +94,9 @@ async function updateUserHandler(req, { params }) {
|
|||||||
|
|
||||||
// DELETE: Delete user (admin only)
|
// DELETE: Delete user (admin only)
|
||||||
async function deleteUserHandler(req, { params }) {
|
async function deleteUserHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const userId = params.id;
|
const userId = id;
|
||||||
|
|
||||||
// Prevent admin from deleting themselves
|
// Prevent admin from deleting themselves
|
||||||
if (userId === req.user.id) {
|
if (userId === req.user.id) {
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ async function createUserHandler(req) {
|
|||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!data.name || !data.email || !data.password) {
|
if (!data.name || !data.username || !data.password) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Name, email, and password are required" },
|
{ error: "Name, username, and password are required" },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,7 +43,7 @@ async function createUserHandler(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate role
|
// 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)) {
|
if (data.role && !validRoles.includes(data.role)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Invalid role specified" },
|
{ error: "Invalid role specified" },
|
||||||
@@ -53,7 +53,7 @@ async function createUserHandler(req) {
|
|||||||
|
|
||||||
const newUser = await createUser({
|
const newUser = await createUser({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
email: data.email,
|
username: data.username,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
role: data.role || "user",
|
role: data.role || "user",
|
||||||
is_active: data.is_active !== undefined ? data.is_active : true
|
is_active: data.is_active !== undefined ? data.is_active : true
|
||||||
@@ -68,7 +68,7 @@ async function createUserHandler(req) {
|
|||||||
|
|
||||||
if (error.message.includes("already exists")) {
|
if (error.message.includes("already exists")) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "A user with this email already exists" },
|
{ error: "A user with this username already exists" },
|
||||||
{ status: 409 }
|
{ status: 409 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/app/api/audit-logs/log/route.js
Normal file
49
src/app/api/audit-logs/log/route.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { logAuditEvent } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const data = await request.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Action is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the audit event
|
||||||
|
await logAuditEvent({
|
||||||
|
action,
|
||||||
|
userId,
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
details,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit log API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/audit-logs/route.js
Normal file
67
src/app/api/audit-logs/route.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Force this API route to use Node.js runtime
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getAuditLogs, getAuditLogStats } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins and project managers can view audit logs
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse query parameters
|
||||||
|
const filters = {
|
||||||
|
userId: searchParams.get("userId") || null,
|
||||||
|
action: searchParams.get("action") || null,
|
||||||
|
resourceType: searchParams.get("resourceType") || null,
|
||||||
|
resourceId: searchParams.get("resourceId") || null,
|
||||||
|
startDate: searchParams.get("startDate") || null,
|
||||||
|
endDate: searchParams.get("endDate") || null,
|
||||||
|
limit: parseInt(searchParams.get("limit")) || 100,
|
||||||
|
offset: parseInt(searchParams.get("offset")) || 0,
|
||||||
|
orderBy: searchParams.get("orderBy") || "timestamp",
|
||||||
|
orderDirection: searchParams.get("orderDirection") || "DESC",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get audit logs
|
||||||
|
const logs = await getAuditLogs(filters);
|
||||||
|
|
||||||
|
// Get statistics if requested
|
||||||
|
const includeStats = searchParams.get("includeStats") === "true";
|
||||||
|
let stats = null;
|
||||||
|
|
||||||
|
if (includeStats) {
|
||||||
|
stats = await getAuditLogStats({
|
||||||
|
startDate: filters.startDate,
|
||||||
|
endDate: filters.endDate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: logs,
|
||||||
|
stats,
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
total: logs.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit logs API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/app/api/audit-logs/stats/route.js
Normal file
41
src/app/api/audit-logs/stats/route.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Force this API route to use Node.js runtime
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getAuditLogStats } from "@/lib/auditLog";
|
||||||
|
|
||||||
|
export async function GET(request) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only admins and project managers can view audit log statistics
|
||||||
|
if (!["admin", "project_manager"].includes(session.user.role)) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
startDate: searchParams.get("startDate") || null,
|
||||||
|
endDate: searchParams.get("endDate") || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = await getAuditLogStats(filters);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Audit log stats API error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
import { withReadAuth, withTeamLeadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
async function getContractHandler(req, { params }) {
|
async function getContractHandler(req, { params }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -21,6 +21,79 @@ async function getContractHandler(req, { params }) {
|
|||||||
return NextResponse.json(contract);
|
return NextResponse.json(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateContractHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await req.json();
|
||||||
|
const {
|
||||||
|
contract_number,
|
||||||
|
contract_name,
|
||||||
|
customer_contract_number,
|
||||||
|
customer,
|
||||||
|
investor,
|
||||||
|
date_signed,
|
||||||
|
finish_date,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
// Check if contract exists
|
||||||
|
const existingContract = db
|
||||||
|
.prepare("SELECT * FROM contracts WHERE contract_id = ?")
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
if (!existingContract) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Contract not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the contract
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE contracts
|
||||||
|
SET contract_number = ?,
|
||||||
|
contract_name = ?,
|
||||||
|
customer_contract_number = ?,
|
||||||
|
customer = ?,
|
||||||
|
investor = ?,
|
||||||
|
date_signed = ?,
|
||||||
|
finish_date = ?
|
||||||
|
WHERE contract_id = ?`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
contract_number,
|
||||||
|
contract_name || null,
|
||||||
|
customer_contract_number || null,
|
||||||
|
customer || null,
|
||||||
|
investor || null,
|
||||||
|
date_signed || null,
|
||||||
|
finish_date || null,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update contract" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and return the updated contract
|
||||||
|
const updatedContract = db
|
||||||
|
.prepare("SELECT * FROM contracts WHERE contract_id = ?")
|
||||||
|
.get(id);
|
||||||
|
|
||||||
|
return NextResponse.json(updatedContract);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating contract:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteContractHandler(req, { params }) {
|
async function deleteContractHandler(req, { params }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
@@ -61,4 +134,5 @@ async function deleteContractHandler(req, { params }) {
|
|||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
export const GET = withReadAuth(getContractHandler);
|
export const GET = withReadAuth(getContractHandler);
|
||||||
export const DELETE = withUserAuth(deleteContractHandler);
|
export const PUT = withUserAuth(updateContractHandler);
|
||||||
|
export const DELETE = withTeamLeadAuth(deleteContractHandler);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ async function getContractsHandler() {
|
|||||||
contract_id,
|
contract_id,
|
||||||
contract_number,
|
contract_number,
|
||||||
contract_name,
|
contract_name,
|
||||||
|
customer_contract_number,
|
||||||
customer,
|
customer,
|
||||||
investor,
|
investor,
|
||||||
date_signed,
|
date_signed,
|
||||||
@@ -24,7 +25,7 @@ async function getContractsHandler() {
|
|||||||
|
|
||||||
async function createContractHandler(req) {
|
async function createContractHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
db.prepare(
|
const result = db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO contracts (
|
INSERT INTO contracts (
|
||||||
contract_number,
|
contract_number,
|
||||||
@@ -45,7 +46,10 @@ async function createContractHandler(req) {
|
|||||||
data.date_signed,
|
data.date_signed,
|
||||||
data.finish_date
|
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
|
// Protected routes - require authentication
|
||||||
|
|||||||
316
src/app/api/dashboard/route.js
Normal file
316
src/app/api/dashboard/route.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
// 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
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate values by contract
|
||||||
|
const contractSummary = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by contract
|
||||||
|
if (project.contract_number && project.wartosc_zlecenia && project.project_status !== 'cancelled') {
|
||||||
|
const contractKey = project.contract_number;
|
||||||
|
if (!contractSummary[contractKey]) {
|
||||||
|
contractSummary[contractKey] = {
|
||||||
|
contract_name: project.contract_name || project.contract_number,
|
||||||
|
realisedValue: 0,
|
||||||
|
unrealisedValue: 0,
|
||||||
|
totalValue: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.project_status === 'fulfilled' && project.completion_date) {
|
||||||
|
contractSummary[contractKey].realisedValue += value;
|
||||||
|
} else {
|
||||||
|
contractSummary[contractKey].unrealisedValue += value;
|
||||||
|
}
|
||||||
|
contractSummary[contractKey].totalValue += 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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
byContract: {
|
||||||
|
'UMK/001/2024': {
|
||||||
|
contract_name: 'Modernizacja budynku głównego',
|
||||||
|
realisedValue: 320000,
|
||||||
|
unrealisedValue: 180000,
|
||||||
|
totalValue: 500000
|
||||||
|
},
|
||||||
|
'UMK/002/2024': {
|
||||||
|
contract_name: 'Budowa parkingu wielopoziomowego',
|
||||||
|
realisedValue: 480000,
|
||||||
|
unrealisedValue: 320000,
|
||||||
|
totalValue: 800000
|
||||||
|
},
|
||||||
|
'UMK/003/2024': {
|
||||||
|
contract_name: 'Remont elewacji',
|
||||||
|
realisedValue: 158000,
|
||||||
|
unrealisedValue: 242000,
|
||||||
|
totalValue: 400000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} 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)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
),
|
||||||
|
byContract: Object.fromEntries(
|
||||||
|
Object.entries(contractSummary).map(([contractNumber, data]) => [
|
||||||
|
contractNumber,
|
||||||
|
{
|
||||||
|
contract_name: data.contract_name,
|
||||||
|
realisedValue: Math.round(data.realisedValue),
|
||||||
|
unrealisedValue: Math.round(data.unrealisedValue),
|
||||||
|
totalValue: Math.round(data.totalValue)
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} 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);
|
||||||
@@ -1,6 +1,60 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import db from "@/lib/db";
|
import db from "@/lib/db";
|
||||||
import { NextResponse } from "next/server";
|
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) {
|
async function createNoteHandler(req) {
|
||||||
const { project_id, task_id, note } = await req.json();
|
const { project_id, task_id, note } = await req.json();
|
||||||
@@ -9,42 +63,80 @@ async function createNoteHandler(req) {
|
|||||||
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
try {
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO notes (project_id, task_id, note, created_by, note_date)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
|
||||||
`
|
`
|
||||||
INSERT INTO notes (project_id, task_id, note)
|
)
|
||||||
VALUES (?, ?, ?)
|
.run(project_id || null, task_id || null, note, req.user?.id || null);
|
||||||
`
|
|
||||||
).run(project_id || null, task_id || null, note);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
// 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,
|
||||||
|
AUDIT_ACTIONS.NOTE_CREATE,
|
||||||
|
RESOURCE_TYPES.NOTE,
|
||||||
|
result.lastInsertRowid.toString(),
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
noteData: { project_id, task_id, note_length: note.length },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(createdNote);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating note:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create note", details: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteNoteHandler(_, { params }) {
|
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);
|
||||||
|
|
||||||
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
db.prepare("DELETE FROM notes WHERE note_id = ?").run(id);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
// Log note deletion
|
||||||
}
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
async function updateNoteHandler(req, { params }) {
|
AUDIT_ACTIONS.NOTE_DELETE,
|
||||||
const noteId = params.id;
|
RESOURCE_TYPES.NOTE,
|
||||||
const { note } = await req.json();
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
if (!note || !noteId) {
|
{
|
||||||
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
|
deletedNote: {
|
||||||
}
|
project_id: note?.project_id,
|
||||||
|
task_id: note?.task_id,
|
||||||
db.prepare(
|
note_length: note?.note?.length || 0,
|
||||||
`
|
},
|
||||||
UPDATE notes SET note = ? WHERE note_id = ?
|
}
|
||||||
`
|
);
|
||||||
).run(note, noteId);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
|
export const GET = withReadAuth(getNotesHandler);
|
||||||
export const POST = withUserAuth(createNoteHandler);
|
export const POST = withUserAuth(createNoteHandler);
|
||||||
export const DELETE = withUserAuth(deleteNoteHandler);
|
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 {
|
import {
|
||||||
updateProjectTaskStatus,
|
updateProjectTaskStatus,
|
||||||
deleteProjectTask,
|
deleteProjectTask,
|
||||||
|
updateProjectTask,
|
||||||
} from "@/lib/queries/tasks";
|
} from "@/lib/queries/tasks";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withUserAuth } from "@/lib/middleware/auth";
|
import { withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
// PATCH: Update project task status
|
// PUT: Update project task (general update)
|
||||||
async function updateProjectTaskHandler(req, { params }) {
|
async function updateProjectTaskHandler(req, { params }) {
|
||||||
try {
|
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();
|
const { status } = await req.json();
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
@@ -17,11 +49,20 @@ async function updateProjectTaskHandler(req, { params }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProjectTaskStatus(params.id, status);
|
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 });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error updating task status:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to update project task" },
|
{ error: "Failed to update project task", details: error.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -30,16 +71,19 @@ async function updateProjectTaskHandler(req, { params }) {
|
|||||||
// DELETE: Delete a project task
|
// DELETE: Delete a project task
|
||||||
async function deleteProjectTaskHandler(req, { params }) {
|
async function deleteProjectTaskHandler(req, { params }) {
|
||||||
try {
|
try {
|
||||||
deleteProjectTask(params.id);
|
const { id } = await params;
|
||||||
|
const result = deleteProjectTask(id);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error in deleteProjectTaskHandler:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to delete project task" },
|
{ error: "Failed to delete project task", details: error.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
export const PATCH = withUserAuth(updateProjectTaskHandler);
|
export const PUT = withUserAuth(updateProjectTaskHandler);
|
||||||
|
export const PATCH = withUserAuth(updateProjectTaskStatusHandler);
|
||||||
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
export const DELETE = withUserAuth(deleteProjectTaskHandler);
|
||||||
|
|||||||
@@ -43,11 +43,29 @@ async function createProjectTaskHandler(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = createProjectTask(data);
|
// Add user tracking information from authenticated session
|
||||||
|
const taskData = {
|
||||||
|
...data,
|
||||||
|
created_by: 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 });
|
return NextResponse.json({ success: true, id: result.lastInsertRowid });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error creating project task:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to create project task" },
|
{ error: "Failed to create project task", details: error.message },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/app/api/project-tasks/users/route.js
Normal file
50
src/app/api/project-tasks/users/route.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
updateProjectTaskAssignment,
|
||||||
|
getAllUsersForTaskAssignment,
|
||||||
|
} from "@/lib/queries/tasks";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth, withReadAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// GET: Get all users for task assignment
|
||||||
|
async function getUsersForTaskAssignmentHandler(req) {
|
||||||
|
try {
|
||||||
|
const users = getAllUsersForTaskAssignment();
|
||||||
|
return NextResponse.json(users);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch users" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Update task assignment
|
||||||
|
async function updateTaskAssignmentHandler(req) {
|
||||||
|
try {
|
||||||
|
const { taskId, assignedToUserId } = await req.json();
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "taskId is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = updateProjectTaskAssignment(taskId, assignedToUserId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update task assignment" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
export const GET = withReadAuth(getUsersForTaskAssignmentHandler);
|
||||||
|
export const POST = withUserAuth(updateTaskAssignmentHandler);
|
||||||
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);
|
||||||
@@ -1,28 +1,158 @@
|
|||||||
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getProjectById,
|
getProjectById,
|
||||||
|
getProjectWithContract,
|
||||||
updateProject,
|
updateProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
} from "@/lib/queries/projects";
|
} 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 { NextResponse } from "next/server";
|
||||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
import { withReadAuth, withUserAuth, withTeamLeadAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
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 = getProjectWithContract(parseInt(id));
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return NextResponse.json({ error: "Project not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log project view
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{ project_name: project.project_name }
|
||||||
|
);
|
||||||
|
|
||||||
async function getProjectHandler(_, { params }) {
|
|
||||||
const project = getProjectById(params.id);
|
|
||||||
return NextResponse.json(project);
|
return NextResponse.json(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateProjectHandler(req, { params }) {
|
async function updateProjectHandler(req, { params }) {
|
||||||
const data = await req.json();
|
try {
|
||||||
updateProject(params.id, data);
|
const { id } = await params;
|
||||||
return NextResponse.json({ success: true });
|
const data = await req.json();
|
||||||
|
|
||||||
|
// Get user ID from authenticated request
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const updatedProject = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
// Log project update
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_UPDATE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
originalData: originalProject,
|
||||||
|
updatedData: data,
|
||||||
|
changedFields: Object.keys(data),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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(_, { params }) {
|
async function deleteProjectHandler(req, { params }) {
|
||||||
deleteProject(params.id);
|
const { id } = await params;
|
||||||
|
|
||||||
|
// Get project data before deletion for audit log
|
||||||
|
const project = getProjectById(parseInt(id));
|
||||||
|
|
||||||
|
deleteProject(parseInt(id));
|
||||||
|
|
||||||
|
// Log project deletion
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_DELETE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
id,
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
deletedProject: {
|
||||||
|
project_name: project?.project_name,
|
||||||
|
project_number: project?.project_number,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
export const GET = withReadAuth(getProjectHandler);
|
export const GET = withReadAuth(getProjectHandler);
|
||||||
export const PUT = withUserAuth(updateProjectHandler);
|
export const PUT = withUserAuth(updateProjectHandler);
|
||||||
export const DELETE = withUserAuth(deleteProjectHandler);
|
export const DELETE = withTeamLeadAuth(deleteProjectHandler);
|
||||||
|
|||||||
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);
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { getAllProjects, createProject } from "@/lib/queries/projects";
|
// Force this API route to use Node.js runtime for database access
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAllProjects,
|
||||||
|
createProject,
|
||||||
|
getAllUsersForAssignment,
|
||||||
|
} from "@/lib/queries/projects";
|
||||||
import initializeDatabase from "@/lib/init-db";
|
import initializeDatabase from "@/lib/init-db";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
import { withReadAuth, withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
import {
|
||||||
|
logApiActionSafe,
|
||||||
|
AUDIT_ACTIONS,
|
||||||
|
RESOURCE_TYPES,
|
||||||
|
} from "@/lib/auditLogSafe.js";
|
||||||
|
|
||||||
// Make sure the DB is initialized before queries run
|
// Make sure the DB is initialized before queries run
|
||||||
initializeDatabase();
|
initializeDatabase();
|
||||||
@@ -9,15 +21,68 @@ initializeDatabase();
|
|||||||
async function getProjectsHandler(req) {
|
async function getProjectsHandler(req) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const contractId = searchParams.get("contract_id");
|
const contractId = searchParams.get("contract_id");
|
||||||
|
const assignedTo = searchParams.get("assigned_to");
|
||||||
|
const createdBy = searchParams.get("created_by");
|
||||||
|
|
||||||
|
let projects;
|
||||||
|
|
||||||
|
if (assignedTo) {
|
||||||
|
const { getProjectsByAssignedUser } = await import(
|
||||||
|
"@/lib/queries/projects"
|
||||||
|
);
|
||||||
|
projects = getProjectsByAssignedUser(assignedTo);
|
||||||
|
} else if (createdBy) {
|
||||||
|
const { getProjectsByCreator } = await import("@/lib/queries/projects");
|
||||||
|
projects = getProjectsByCreator(createdBy);
|
||||||
|
} else {
|
||||||
|
projects = getAllProjects(contractId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log project list access
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_VIEW,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
null, // No specific project ID for list view
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
filters: { contractId, assignedTo, createdBy },
|
||||||
|
resultCount: projects.length,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const projects = getAllProjects(contractId);
|
|
||||||
return NextResponse.json(projects);
|
return NextResponse.json(projects);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createProjectHandler(req) {
|
async function createProjectHandler(req) {
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
createProject(data);
|
|
||||||
return NextResponse.json({ success: true });
|
// Get user ID from authenticated request
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
const result = createProject(data, userId);
|
||||||
|
const projectId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Log project creation
|
||||||
|
await logApiActionSafe(
|
||||||
|
req,
|
||||||
|
AUDIT_ACTIONS.PROJECT_CREATE,
|
||||||
|
RESOURCE_TYPES.PROJECT,
|
||||||
|
projectId.toString(),
|
||||||
|
req.auth, // Use req.auth instead of req.session
|
||||||
|
{
|
||||||
|
projectData: {
|
||||||
|
project_name: data.project_name,
|
||||||
|
project_number: data.project_number,
|
||||||
|
contract_id: data.contract_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
projectId: projectId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protected routes - require authentication
|
// Protected routes - require authentication
|
||||||
|
|||||||
33
src/app/api/projects/users/route.js
Normal file
33
src/app/api/projects/users/route.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
getAllUsersForAssignment,
|
||||||
|
updateProjectAssignment,
|
||||||
|
} from "@/lib/queries/projects";
|
||||||
|
import initializeDatabase from "@/lib/init-db";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUserAuth } from "@/lib/middleware/auth";
|
||||||
|
|
||||||
|
// Make sure the DB is initialized before queries run
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
async function getUsersHandler(req) {
|
||||||
|
const users = getAllUsersForAssignment();
|
||||||
|
return NextResponse.json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateAssignmentHandler(req) {
|
||||||
|
const { projectId, assignedToUserId } = await req.json();
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Project ID is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProjectAssignment(projectId, assignedToUserId);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes - require authentication
|
||||||
|
export const GET = withUserAuth(getUsersHandler);
|
||||||
|
export const POST = withUserAuth(updateAssignmentHandler);
|
||||||
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);
|
||||||
@@ -38,7 +38,7 @@ async function addTaskNoteHandler(req) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
addNoteToTask(task_id, note, is_system);
|
addNoteToTask(task_id, note, is_system, req.user?.id || null);
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding task note:", error);
|
console.error("Error adding task note:", error);
|
||||||
|
|||||||
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
|
// GET: Get a specific task template
|
||||||
async function getTaskHandler(req, { params }) {
|
async function getTaskHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const template = db
|
const template = db
|
||||||
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
.prepare("SELECT * FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||||
.get(params.id);
|
.get(id);
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -27,20 +28,25 @@ async function getTaskHandler(req, { params }) {
|
|||||||
|
|
||||||
// PUT: Update a task template
|
// PUT: Update a task template
|
||||||
async function updateTaskHandler(req, { params }) {
|
async function updateTaskHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const { name, max_wait_days, description } = await req.json();
|
const { name, max_wait_days, description, task_category } = await req.json();
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
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
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE tasks
|
`UPDATE tasks
|
||||||
SET name = ?, max_wait_days = ?, description = ?
|
SET name = ?, max_wait_days = ?, description = ?, task_category = ?
|
||||||
WHERE task_id = ? AND is_standard = 1`
|
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) {
|
if (result.changes === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -60,10 +66,11 @@ async function updateTaskHandler(req, { params }) {
|
|||||||
|
|
||||||
// DELETE: Delete a task template
|
// DELETE: Delete a task template
|
||||||
async function deleteTaskHandler(req, { params }) {
|
async function deleteTaskHandler(req, { params }) {
|
||||||
|
const { id } = await params;
|
||||||
try {
|
try {
|
||||||
const result = db
|
const result = db
|
||||||
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
.prepare("DELETE FROM tasks WHERE task_id = ? AND is_standard = 1")
|
||||||
.run(params.id);
|
.run(id);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -5,18 +5,22 @@ import { getAllTaskTemplates } from "@/lib/queries/tasks";
|
|||||||
|
|
||||||
// POST: create new template
|
// POST: create new template
|
||||||
async function createTaskHandler(req) {
|
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) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
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(
|
db.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO tasks (name, max_wait_days, description, is_standard)
|
INSERT INTO tasks (name, max_wait_days, description, is_standard, task_category)
|
||||||
VALUES (?, ?, ?, 1)
|
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 });
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
|
import { Suspense } from 'react'
|
||||||
|
|
||||||
export default function AuthError() {
|
function AuthErrorContent() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const error = searchParams.get('error')
|
const error = searchParams.get('error')
|
||||||
|
|
||||||
@@ -47,3 +48,18 @@ export default function AuthError() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AuthError() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<AuthErrorContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, Suspense } from "react"
|
||||||
import { signIn, getSession } from "next-auth/react"
|
import { signIn, getSession } from "next-auth/react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
export default function SignIn() {
|
function SignInContent() {
|
||||||
const [email, setEmail] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -21,13 +21,13 @@ export default function SignIn() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await signIn("credentials", {
|
const result = await signIn("credentials", {
|
||||||
email,
|
username,
|
||||||
password,
|
password,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError("Invalid email or password")
|
setError("Invalid username or password")
|
||||||
} else {
|
} else {
|
||||||
// Successful login
|
// Successful login
|
||||||
router.push(callbackUrl)
|
router.push(callbackUrl)
|
||||||
@@ -45,10 +45,10 @@ export default function SignIn() {
|
|||||||
<div className="max-w-md w-full space-y-8">
|
<div className="max-w-md w-full space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
Sign in to your account
|
Zaloguj się do swojego konta
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-gray-600">
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
Access the Project Management Panel
|
Dostęp do panelu
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
@@ -60,24 +60,24 @@ export default function SignIn() {
|
|||||||
|
|
||||||
<div className="rounded-md shadow-sm -space-y-px">
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="sr-only">
|
<label htmlFor="username" className="sr-only">
|
||||||
Email address
|
Nazwa użytkownika
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="username"
|
||||||
name="email"
|
name="username"
|
||||||
type="email"
|
type="text"
|
||||||
autoComplete="email"
|
autoComplete="username"
|
||||||
required
|
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"
|
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"
|
placeholder="Nazwa użytkownika"
|
||||||
value={email}
|
value={username}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="sr-only">
|
<label htmlFor="password" className="sr-only">
|
||||||
Password
|
Hasło
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -105,7 +105,7 @@ export default function SignIn() {
|
|||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
<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>
|
<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>
|
</svg>
|
||||||
Signing in...
|
Zaloguj...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"Sign in"
|
"Sign in"
|
||||||
@@ -113,15 +113,30 @@ export default function SignIn() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center">
|
{/* <div className="text-center">
|
||||||
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded">
|
||||||
<p className="font-medium">Default Admin Account:</p>
|
<p className="font-medium">Default Admin Account:</p>
|
||||||
<p>Email: admin@localhost</p>
|
<p>Email: admin@localhost</p>
|
||||||
<p>Password: admin123456</p>
|
<p>Password: admin123456</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function SignIn() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<SignInContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import ComprehensivePolishMap from '../../components/ui/ComprehensivePolishMap';
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
628
src/app/contacts/page.js
Normal file
628
src/app/contacts/page.js
Normal file
@@ -0,0 +1,628 @@
|
|||||||
|
"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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'all' ? 'ring-2 ring-gray-900' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('all')}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'project' ? 'ring-2 ring-blue-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('project')}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'contractor' ? 'ring-2 ring-orange-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('contractor')}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'office' ? 'ring-2 ring-purple-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('office')}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'supplier' ? 'ring-2 ring-green-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('supplier')}
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
className={`cursor-pointer transition-all hover:shadow-lg ${typeFilter === 'other' ? 'ring-2 ring-gray-600' : ''}`}
|
||||||
|
onClick={() => setTypeFilter('other')}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
src/app/contracts/[id]/edit/page.js
Normal file
139
src/app/contracts/[id]/edit/page.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import ContractForm from "@/components/ContractForm";
|
||||||
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
|
import Button from "@/components/ui/Button";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LoadingState } from "@/components/ui/States";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
|
export default function EditContractPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id;
|
||||||
|
const [contract, setContract] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchContract() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/contracts/${id}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch contract");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setContract(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching contract:", error);
|
||||||
|
setError("Nie udało się pobrać danych umowy.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
fetchContract();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={t('contracts.editContractDescription')}
|
||||||
|
/>
|
||||||
|
<LoadingState message={t('navigation.loading')} />
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !contract) {
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={t('contracts.editContractDescription')}
|
||||||
|
/>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start mb-6">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-red-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800">
|
||||||
|
{error || "Nie znaleziono umowy."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/contracts">
|
||||||
|
<Button variant="outline">
|
||||||
|
<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="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('contracts.backToContracts')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title={t('contracts.editContract')}
|
||||||
|
description={`${t('contracts.editing')} ${contract.contract_number}`}
|
||||||
|
action={
|
||||||
|
<Link href={`/contracts/${id}`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<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="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('contracts.backToContract')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<ContractForm initialData={contract} />
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import PageContainer from "@/components/ui/PageContainer";
|
|||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import { LoadingState } from "@/components/ui/States";
|
import { LoadingState } from "@/components/ui/States";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import FileUploadModal from "@/components/FileUploadModal";
|
||||||
|
import FileAttachmentsList from "@/components/FileAttachmentsList";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function ContractDetailsPage() {
|
export default function ContractDetailsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -17,6 +20,9 @@ export default function ContractDetailsPage() {
|
|||||||
const [contract, setContract] = useState(null);
|
const [contract, setContract] = useState(null);
|
||||||
const [projects, setProjects] = useState([]);
|
const [projects, setProjects] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [attachments, setAttachments] = useState([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchContractDetails() {
|
async function fetchContractDetails() {
|
||||||
@@ -52,10 +58,18 @@ export default function ContractDetailsPage() {
|
|||||||
fetchContractDetails();
|
fetchContractDetails();
|
||||||
}
|
}
|
||||||
}, [contractId]);
|
}, [contractId]);
|
||||||
|
const handleFileUploaded = (newFile) => {
|
||||||
|
setAttachments(prev => [newFile, ...prev]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFilesChange = (files) => {
|
||||||
|
setAttachments(files);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<LoadingState message="Loading contract details..." />
|
<LoadingState message={t('contracts.loadingContractDetails')} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,9 +79,9 @@ export default function ContractDetailsPage() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-12">
|
<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">
|
<Link href="/contracts">
|
||||||
<Button variant="primary">Back to Contracts</Button>
|
<Button variant="primary">{t('contracts.backToContracts')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -77,8 +91,8 @@ export default function ContractDetailsPage() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={`Contract ${contract.contract_number}`}
|
title={`${t('contracts.contract')} ${contract.contract_number}`}
|
||||||
description={contract.contract_name || "Contract Details"}
|
description={contract.contract_name || t('contracts.contractInformation')}
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Link href="/contracts">
|
<Link href="/contracts">
|
||||||
@@ -96,7 +110,25 @@ export default function ContractDetailsPage() {
|
|||||||
d="M15 19l-7-7 7-7"
|
d="M15 19l-7-7 7-7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Contracts
|
{t('contracts.backToContracts')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href={`/contracts/${contractId}/edit`}>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<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>
|
||||||
|
{t('contracts.editContract')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||||
@@ -114,7 +146,7 @@ export default function ContractDetailsPage() {
|
|||||||
d="M12 4v16m8-8H4"
|
d="M12 4v16m8-8H4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Project
|
{t('contracts.addProject')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,14 +159,14 @@ export default function ContractDetailsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
Contract Information
|
{t('contracts.contractInformation')}
|
||||||
</h2>
|
</h2>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Contract Number
|
{t('contracts.contractNumber')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{contract.contract_number}
|
{contract.contract_number}
|
||||||
@@ -143,7 +175,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.contract_name && (
|
{contract.contract_name && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Contract Name
|
{t('contracts.contractName')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{contract.contract_name}
|
{contract.contract_name}
|
||||||
@@ -153,7 +185,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.customer_contract_number && (
|
{contract.customer_contract_number && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Customer Contract Number
|
{t('contracts.customerContractNumber')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{contract.customer_contract_number}
|
{contract.customer_contract_number}
|
||||||
@@ -163,7 +195,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.customer && (
|
{contract.customer && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Customer
|
{t('contracts.customer')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{contract.customer}
|
{contract.customer}
|
||||||
@@ -173,7 +205,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.investor && (
|
{contract.investor && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Investor
|
{t('contracts.investor')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{contract.investor}
|
{contract.investor}
|
||||||
@@ -183,7 +215,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.date_signed && (
|
{contract.date_signed && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Date Signed
|
{t('contracts.dateSigned')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{formatDate(contract.date_signed)}
|
{formatDate(contract.date_signed)}
|
||||||
@@ -193,7 +225,7 @@ export default function ContractDetailsPage() {
|
|||||||
{contract.finish_date && (
|
{contract.finish_date && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-1">
|
<span className="text-sm font-medium text-gray-500 block mb-1">
|
||||||
Finish Date
|
{t('contracts.finishDate')}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-gray-900 font-medium">
|
<p className="text-gray-900 font-medium">
|
||||||
{formatDate(contract.finish_date)}
|
{formatDate(contract.finish_date)}
|
||||||
@@ -209,22 +241,22 @@ export default function ContractDetailsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||||
Projects Count
|
{t('contracts.projectsCount')}
|
||||||
</span>
|
</span>
|
||||||
<Badge variant="primary" size="lg">
|
<Badge variant="primary" size="lg">
|
||||||
{projects.length} Projects
|
{projects.length} {t('contracts.projects')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contract.finish_date && (
|
{contract.finish_date && (
|
||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<span className="text-sm font-medium text-gray-500 block mb-2">
|
<span className="text-sm font-medium text-gray-500 block mb-2">
|
||||||
Contract Status
|
{t('contracts.contractStatus')}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
@@ -235,8 +267,8 @@ export default function ContractDetailsPage() {
|
|||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
{new Date(contract.finish_date) > new Date()
|
{new Date(contract.finish_date) > new Date()
|
||||||
? "Active"
|
? t('contracts.active')
|
||||||
: "Expired"}
|
: t('contracts.expired')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -245,12 +277,50 @@ export default function ContractDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Associated Projects */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h2 className="text-xl font-semibold text-gray-900">
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
Associated Projects ({projects.length})
|
{t('contracts.associatedProjects')} ({projects.length})
|
||||||
</h2>
|
</h2>
|
||||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -267,7 +337,7 @@ export default function ContractDetailsPage() {
|
|||||||
d="M12 4v16m8-8H4"
|
d="M12 4v16m8-8H4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Add Project
|
{t('contracts.addProject')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,13 +359,13 @@ export default function ContractDetailsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
No projects yet
|
{t('contracts.noProjectsYet')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 mb-6">
|
<p className="text-gray-500 mb-6">
|
||||||
Get started by creating your first project for this contract
|
{t('contracts.getStartedMessage')}
|
||||||
</p>
|
</p>
|
||||||
<Link href={`/projects/new?contract_id=${contractId}`}>
|
<Link href={`/projects/new?contract_id=${contractId}`}>
|
||||||
<Button variant="primary">Create First Project</Button>
|
<Button variant="primary">{t('contracts.createFirstProject')}</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -361,22 +431,22 @@ export default function ContractDetailsPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{project.project_status === "registered"
|
{project.project_status === "registered"
|
||||||
? "Registered"
|
? t('projectStatus.registered')
|
||||||
: project.project_status === "in_progress_design"
|
: project.project_status === "in_progress_design"
|
||||||
? "In Progress (Design)"
|
? t('projectStatus.in_progress_design')
|
||||||
: project.project_status ===
|
: project.project_status ===
|
||||||
"in_progress_construction"
|
"in_progress_construction"
|
||||||
? "In Progress (Construction)"
|
? t('projectStatus.in_progress_construction')
|
||||||
: project.project_status === "fulfilled"
|
: project.project_status === "fulfilled"
|
||||||
? "Completed"
|
? t('projectStatus.fulfilled')
|
||||||
: "Unknown"}
|
: t('projectStatus.unknown')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/projects/${project.project_id}`}>
|
<Link href={`/projects/${project.project_id}`}>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
View Details
|
{t('contracts.viewDetails')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,6 +456,15 @@ export default function ContractDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* File Upload Modal */}
|
||||||
|
<FileUploadModal
|
||||||
|
isOpen={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
entityType="contract"
|
||||||
|
entityId={contractId}
|
||||||
|
onFileUploaded={handleFileUploaded}
|
||||||
|
/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import ContractForm from "@/components/ContractForm";
|
import ContractForm from "@/components/ContractForm";
|
||||||
import PageContainer from "@/components/ui/PageContainer";
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
import PageHeader from "@/components/ui/PageHeader";
|
import PageHeader from "@/components/ui/PageHeader";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function NewContractPage() {
|
export default function NewContractPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Create New Contract"
|
title={t('contracts.createNewContract')}
|
||||||
description="Add a new contract to your portfolio"
|
description={t('contracts.addNewContractDescription')}
|
||||||
action={
|
action={
|
||||||
<Link href="/contracts">
|
<Link href="/contracts">
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
@@ -26,7 +30,7 @@ export default function NewContractPage() {
|
|||||||
d="M15 19l-7-7 7-7"
|
d="M15 19l-7-7 7-7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Contracts
|
{t('contracts.backToContracts')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
@@ -11,26 +12,39 @@ import SearchBar from "@/components/ui/SearchBar";
|
|||||||
import FilterBar from "@/components/ui/FilterBar";
|
import FilterBar from "@/components/ui/FilterBar";
|
||||||
import { LoadingState } from "@/components/ui/States";
|
import { LoadingState } from "@/components/ui/States";
|
||||||
import { formatDate } from "@/lib/utils";
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
|
||||||
export default function ContractsMainPage() {
|
export default function ContractsMainPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
const [contracts, setContracts] = useState([]);
|
const [contracts, setContracts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
const [filteredContracts, setFilteredContracts] = useState([]);
|
const [filteredContracts, setFilteredContracts] = useState([]);
|
||||||
const [sortBy, setSortBy] = useState("contract_number");
|
const [sortBy, setSortBy] = useState("date_signed");
|
||||||
const [sortOrder, setSortOrder] = useState("asc");
|
const [sortOrder, setSortOrder] = useState("desc");
|
||||||
const [statusFilter, setStatusFilter] = useState("all");
|
const [statusFilter, setStatusFilter] = useState("all");
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [contractToDelete, setContractToDelete] = useState(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [success, setSuccess] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchContracts() {
|
async function fetchContracts() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/contracts");
|
const res = await fetch("/api/contracts");
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error("Failed to fetch contracts");
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setContracts(data);
|
setContracts(data);
|
||||||
setFilteredContracts(data);
|
setFilteredContracts(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching contracts:", error);
|
console.error("Error fetching contracts:", error);
|
||||||
|
setError("Nie udało się pobrać listy umów. Spróbuj ponownie później.");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -51,6 +65,9 @@ export default function ContractsMainPage() {
|
|||||||
contract.contract_name
|
contract.contract_name
|
||||||
?.toLowerCase()
|
?.toLowerCase()
|
||||||
.includes(searchTerm.toLowerCase()) ||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
|
contract.customer_contract_number
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
contract.customer?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
|
contract.investor?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
@@ -62,9 +79,9 @@ export default function ContractsMainPage() {
|
|||||||
filtered = filtered.filter((contract) => {
|
filtered = filtered.filter((contract) => {
|
||||||
if (statusFilter === "active" && contract.finish_date) {
|
if (statusFilter === "active" && contract.finish_date) {
|
||||||
return new Date(contract.finish_date) >= currentDate;
|
return new Date(contract.finish_date) >= currentDate;
|
||||||
} else if (statusFilter === "completed" && contract.finish_date) {
|
} else if (statusFilter === "expired" && contract.finish_date) {
|
||||||
return new Date(contract.finish_date) < currentDate;
|
return new Date(contract.finish_date) < currentDate;
|
||||||
} else if (statusFilter === "no_end_date") {
|
} else if (statusFilter === "ongoing") {
|
||||||
return !contract.finish_date;
|
return !contract.finish_date;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -88,80 +105,84 @@ export default function ContractsMainPage() {
|
|||||||
setFilteredContracts(filtered);
|
setFilteredContracts(filtered);
|
||||||
}, [searchTerm, contracts, sortBy, sortOrder, statusFilter]);
|
}, [searchTerm, contracts, sortBy, sortOrder, statusFilter]);
|
||||||
|
|
||||||
async function handleDelete(id) {
|
|
||||||
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/contracts/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
setContracts(contracts.filter((c) => c.contract_id !== id));
|
|
||||||
} else {
|
|
||||||
alert("Błąd podczas usuwania umowy.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting contract:", error);
|
|
||||||
alert("Błąd podczas usuwania umowy.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get contract statistics
|
|
||||||
const getContractStats = () => {
|
const getContractStats = () => {
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const total = contracts.length;
|
const total = contracts.length;
|
||||||
const active = contracts.filter(
|
const active = contracts.filter(
|
||||||
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
|
(c) => !c.finish_date || new Date(c.finish_date) >= currentDate
|
||||||
).length;
|
).length;
|
||||||
const completed = contracts.filter(
|
const expired = contracts.filter(
|
||||||
(c) => c.finish_date && new Date(c.finish_date) < currentDate
|
(c) => c.finish_date && new Date(c.finish_date) < currentDate
|
||||||
).length;
|
).length;
|
||||||
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
|
const withoutEndDate = contracts.filter((c) => !c.finish_date).length;
|
||||||
|
|
||||||
return { total, active, completed, withoutEndDate };
|
return { total, active, expired, withoutEndDate };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getContractStatus = (contract) => {
|
const getContractStatus = (contract) => {
|
||||||
if (!contract.finish_date) return "ongoing";
|
if (!contract.finish_date) return "ongoing";
|
||||||
const currentDate = new Date();
|
const currentDate = new Date();
|
||||||
const finishDate = new Date(contract.finish_date);
|
const finishDate = new Date(contract.finish_date);
|
||||||
return finishDate >= currentDate ? "active" : "completed";
|
return finishDate >= currentDate ? "active" : "expired";
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "active":
|
case "active":
|
||||||
return <Badge variant="success">Aktywna</Badge>;
|
return <Badge variant="success">{t('contracts.active')}</Badge>;
|
||||||
case "completed":
|
case "expired":
|
||||||
return <Badge variant="secondary">Zakończona</Badge>;
|
return <Badge variant="danger">{t('contracts.expired')}</Badge>;
|
||||||
case "ongoing":
|
case "ongoing":
|
||||||
return <Badge variant="primary">W trakcie</Badge>;
|
return <Badge variant="primary">{t('contracts.withoutEndDate')}</Badge>;
|
||||||
default:
|
default:
|
||||||
return <Badge>Nieznany</Badge>;
|
return <Badge>{t('common.unknown')}</Badge>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleDelete(id) {
|
const initiateDelete = (contract) => {
|
||||||
const confirmed = confirm("Czy na pewno chcesz usunąć tę umowę?");
|
setContractToDelete(contract);
|
||||||
if (!confirmed) return;
|
setShowDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!contractToDelete) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/contracts/${id}`, {
|
const res = await fetch(`/api/contracts/${contractToDelete.contract_id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setContracts(contracts.filter((c) => c.contract_id !== id));
|
setContracts(contracts.filter((c) => c.contract_id !== contractToDelete.contract_id));
|
||||||
|
setSuccess(`Umowa "${contractToDelete.contract_number}" została usunięta.`);
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setContractToDelete(null);
|
||||||
|
|
||||||
|
// Auto-hide success message after 5 seconds
|
||||||
|
setTimeout(() => setSuccess(null), 5000);
|
||||||
} else {
|
} else {
|
||||||
alert("Błąd podczas usuwania umowy.");
|
setError(data.error || "Nie udało się usunąć umowy.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting contract:", error);
|
console.error("Error deleting contract:", error);
|
||||||
alert("Błąd podczas usuwania umowy.");
|
setError("Wystąpił błąd podczas usuwania umowy. Spróbuj ponownie.");
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const cancelDelete = () => {
|
||||||
|
if (!deleting) {
|
||||||
|
setShowDeleteModal(false);
|
||||||
|
setContractToDelete(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSearchChange = (e) => {
|
const handleSearchChange = (e) => {
|
||||||
setSearchTerm(e.target.value);
|
setSearchTerm(e.target.value);
|
||||||
@@ -170,17 +191,29 @@ export default function ContractsMainPage() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Umowy"
|
title={t('contracts.title')}
|
||||||
description="Zarządzaj swoimi umowami i kontraktami"
|
description={t('contracts.subtitle')}
|
||||||
>
|
>
|
||||||
<Link href="/contracts/new">
|
<Link href="/contracts/new">
|
||||||
<Button variant="primary" size="lg">
|
<Button variant="primary" size="lg">
|
||||||
<span className="mr-2">➕</span>
|
<svg
|
||||||
Nowa umowa
|
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>
|
||||||
|
{t('contracts.newContract')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<LoadingState message="Ładowanie umów..." />
|
<LoadingState message={t('navigation.loading')} />
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -195,8 +228,8 @@ export default function ContractsMainPage() {
|
|||||||
options: [
|
options: [
|
||||||
{ value: "all", label: "Wszystkie" },
|
{ value: "all", label: "Wszystkie" },
|
||||||
{ value: "active", label: "Aktywne" },
|
{ value: "active", label: "Aktywne" },
|
||||||
{ value: "completed", label: "Zakończone" },
|
{ value: "expired", label: "Przeterminowane" },
|
||||||
{ value: "no_end_date", label: "Bez daty końca" },
|
{ value: "ongoing", label: "W trakcie" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,7 +240,7 @@ export default function ContractsMainPage() {
|
|||||||
{ value: "contract_number", label: "Numer umowy" },
|
{ value: "contract_number", label: "Numer umowy" },
|
||||||
{ value: "contract_name", label: "Nazwa umowy" },
|
{ value: "contract_name", label: "Nazwa umowy" },
|
||||||
{ value: "customer", label: "Klient" },
|
{ value: "customer", label: "Klient" },
|
||||||
{ value: "start_date", label: "Data rozpoczęcia" },
|
{ value: "date_signed", label: "Data podpisania" },
|
||||||
{ value: "finish_date", label: "Data zakończenia" },
|
{ value: "finish_date", label: "Data zakończenia" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -225,16 +258,89 @@ export default function ContractsMainPage() {
|
|||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Umowy"
|
title={t('contracts.title')}
|
||||||
description="Zarządzaj swoimi umowami i kontraktami"
|
description={t('contracts.subtitle')}
|
||||||
>
|
>
|
||||||
<Link href="/contracts/new">
|
<Link href="/contracts/new">
|
||||||
<Button variant="primary" size="lg">
|
<Button variant="primary" size="lg">
|
||||||
<span className="mr-2">➕</span>
|
<svg
|
||||||
Nowa umowa
|
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>
|
||||||
|
{t('contracts.newContract')}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{success && (
|
||||||
|
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4 flex items-start">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-green-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-800">{success}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuccess(null)}
|
||||||
|
className="text-green-600 hover:text-green-800 ml-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4 flex items-start">
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-red-600 mr-3 mt-0.5 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-red-800">{error}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="text-red-600 hover:text-red-800 ml-3"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -312,9 +418,9 @@ export default function ContractsMainPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className="text-sm font-medium text-gray-600">Zakończone</p>
|
<p className="text-sm font-medium text-gray-600">Przeterminowane</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{stats.completed}
|
{stats.expired}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -544,26 +650,28 @@ export default function ContractsMainPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Szczegóły
|
Szczegóły
|
||||||
</Link>
|
</Link>
|
||||||
<Button
|
{session?.user?.role === 'team_lead' && (
|
||||||
variant="danger"
|
<Button
|
||||||
size="sm"
|
variant="danger"
|
||||||
onClick={() => handleDelete(contract.contract_id)}
|
size="sm"
|
||||||
>
|
onClick={() => initiateDelete(contract)}
|
||||||
<svg
|
|
||||||
className="w-4 h-4 mr-1"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
strokeLinecap="round"
|
className="w-4 h-4 mr-1"
|
||||||
strokeLinejoin="round"
|
fill="none"
|
||||||
strokeWidth={2}
|
stroke="currentColor"
|
||||||
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"
|
viewBox="0 0 24 24"
|
||||||
/>
|
>
|
||||||
</svg>
|
<path
|
||||||
Usuń
|
strokeLinecap="round"
|
||||||
</Button>
|
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>
|
||||||
|
Usuń
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -591,6 +699,124 @@ export default function ContractsMainPage() {
|
|||||||
</p>{" "}
|
</p>{" "}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteModal && contractToDelete && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[9999]"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && !deleting && cancelDelete()}
|
||||||
|
>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md mx-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
className="w-6 h-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="ml-3 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Potwierdź usunięcie
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{!deleting && (
|
||||||
|
<button
|
||||||
|
onClick={cancelDelete}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<svg className="w-6 h-6" 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>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Czy na pewno chcesz usunąć umowę <strong className="font-semibold">"{contractToDelete.contract_number}"</strong>
|
||||||
|
{contractToDelete.contract_name && (
|
||||||
|
<> — <strong className="font-semibold">{contractToDelete.contract_name}</strong></>
|
||||||
|
)}?
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
Ta operacja jest nieodwracalna.
|
||||||
|
</p>
|
||||||
|
{contractToDelete.customer && (
|
||||||
|
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Zleceniodawca: <strong>{contractToDelete.customer}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={cancelDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
Anuluj
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline-block"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Usuwanie...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 mr-2 inline-block"
|
||||||
|
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>
|
||||||
|
Usuń umowę
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
371
src/app/dashboard/page.js
Normal file
371
src/app/dashboard/page.js
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, ComposedChart, PieChart, Pie, Cell } from 'recharts';
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import PageContainer from "@/components/ui/PageContainer";
|
||||||
|
|
||||||
|
export default function TeamLeadsDashboard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [chartData, setChartData] = useState([]);
|
||||||
|
const [summaryData, setSummaryData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [selectedYear, setSelectedYear] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDashboardData();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
const fetchDashboardData = async () => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedYear) {
|
||||||
|
params.append('year', selectedYear.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `/api/dashboard${params.toString() ? '?' + params.toString() : ''}`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch dashboard data');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
setChartData(data.chartData || []);
|
||||||
|
setSummaryData(data.summary || null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const availableYears = [];
|
||||||
|
for (let year = currentYear; year >= 2023; year--) {
|
||||||
|
availableYears.push(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYearChange = (year) => {
|
||||||
|
setSelectedYear(year === 'all' ? null : parseInt(year));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
return new Intl.NumberFormat('pl-PL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'PLN',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
// Find the monthly and cumulative values
|
||||||
|
const monthlyData = payload.find(p => p.dataKey === 'value');
|
||||||
|
const cumulativeData = payload.find(p => p.dataKey === 'cumulative');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white">{`${t('teamDashboard.monthLabel')} ${label}`}</p>
|
||||||
|
<p className="text-blue-600 dark:text-blue-400 font-semibold">
|
||||||
|
{`${t('teamDashboard.monthlyValue')} ${monthlyData ? formatCurrency(monthlyData.value) : t('teamDashboard.na')}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||||
|
{`${t('teamDashboard.cumulative')} ${cumulativeData ? formatCurrency(cumulativeData.value) : t('teamDashboard.na')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('teamDashboard.title')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Year Filter */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label htmlFor="year-select" className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{t('teamDashboard.yearLabel')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="year-select"
|
||||||
|
value={selectedYear || 'all'}
|
||||||
|
onChange={(e) => handleYearChange(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">{t('teamDashboard.allYears')}</option>
|
||||||
|
{availableYears.map(year => (
|
||||||
|
<option key={year} value={year}>{year}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
{t('teamDashboard.projectCompletionTitle')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.loadingChart')}</div>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-red-500 dark:text-red-400">{t('teamDashboard.errorPrefix')} {error}</div>
|
||||||
|
</div>
|
||||||
|
) : chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noData')}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-96">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={12}
|
||||||
|
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="value"
|
||||||
|
fill="#3b82f6"
|
||||||
|
name={t('teamDashboard.monthlyValue').replace(':', '')}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cumulative"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={3}
|
||||||
|
name={t('teamDashboard.cumulative').replace(':', '')}
|
||||||
|
dot={{ fill: '#10b981', strokeWidth: 2, r: 4 }}
|
||||||
|
activeDot={{ r: 6, stroke: '#10b981', strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Main Total Chart */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
{t('teamDashboard.totalPortfolio')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{summaryData?.total ? (
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
name: t('teamDashboard.realised'),
|
||||||
|
value: summaryData.total.realisedValue,
|
||||||
|
color: '#10b981'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('teamDashboard.unrealised'),
|
||||||
|
value: summaryData.total.unrealisedValue,
|
||||||
|
color: '#8b5cf6'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||||
|
>
|
||||||
|
<Cell fill="#10b981" />
|
||||||
|
<Cell fill="#8b5cf6" />
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => formatCurrency(value)} />
|
||||||
|
<Legend />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500 dark:text-gray-400">{t('teamDashboard.noSummaryData')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{summaryData?.total && (
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||||
|
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg">
|
||||||
|
<div className="text-sm text-green-600 dark:text-green-400 font-medium">{t('teamDashboard.realisedValue')}</div>
|
||||||
|
<div className="text-xl font-bold text-green-700 dark:text-green-300">
|
||||||
|
{formatCurrency(summaryData.total.realisedValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
|
||||||
|
<div className="text-sm text-purple-600 dark:text-purple-400 font-medium">{t('teamDashboard.unrealisedValue')}</div>
|
||||||
|
<div className="text-xl font-bold text-purple-700 dark:text-purple-300">
|
||||||
|
{formatCurrency(summaryData.total.unrealisedValue)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Project Type Charts */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
{t('teamDashboard.byProjectType')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{summaryData?.byType && Object.entries(summaryData.byType).map(([type, data]) => (
|
||||||
|
<div key={type} className="bg-white dark:bg-gray-800 p-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-4 capitalize text-center">
|
||||||
|
{t(`projectType.${type}`)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Mini pie chart */}
|
||||||
|
<div className="h-32 mb-4">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={[
|
||||||
|
{ name: t('teamDashboard.realised'), value: data.realisedValue, color: '#10b981' },
|
||||||
|
{ name: t('teamDashboard.unrealised'), value: data.unrealisedValue, color: '#8b5cf6' }
|
||||||
|
]}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={45}
|
||||||
|
dataKey="value"
|
||||||
|
label={({ percent }) => percent > 0.1 ? `${(percent * 100).toFixed(0)}%` : ''}
|
||||||
|
>
|
||||||
|
<Cell fill="#10b981" />
|
||||||
|
<Cell fill="#8b5cf6" />
|
||||||
|
</Pie>
|
||||||
|
<Tooltip formatter={(value) => formatCurrency(value)} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">{t('teamDashboard.realised')}</span>
|
||||||
|
<span className="text-sm font-semibold text-green-700 dark:text-green-300">
|
||||||
|
{formatCurrency(data.realisedValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm text-purple-600 dark:text-purple-400">{t('teamDashboard.unrealised')}</span>
|
||||||
|
<span className="text-sm font-semibold text-purple-700 dark:text-purple-300">
|
||||||
|
{formatCurrency(data.unrealisedValue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By Contract Section */}
|
||||||
|
{summaryData?.byContract && Object.keys(summaryData.byContract).length > 0 && (
|
||||||
|
<div className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
{t('teamDashboard.byContract')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="h-96">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={Object.entries(summaryData.byContract).map(([contractNumber, data]) => ({
|
||||||
|
name: contractNumber,
|
||||||
|
fullName: data.contract_name,
|
||||||
|
realised: data.realisedValue,
|
||||||
|
unrealised: data.unrealisedValue,
|
||||||
|
total: data.totalValue
|
||||||
|
}))}
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
right: 30,
|
||||||
|
left: 20,
|
||||||
|
bottom: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={100}
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={11}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-gray-600 dark:text-gray-400"
|
||||||
|
fontSize={12}
|
||||||
|
tickFormatter={(value) => `${(value / 1000).toFixed(0)}k`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-3 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg">
|
||||||
|
<p className="font-medium text-gray-900 dark:text-white mb-2">{payload[0].payload.fullName}</p>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{payload[0].payload.name}</p>
|
||||||
|
<p className="text-green-600 dark:text-green-400 text-sm">
|
||||||
|
{`${t('teamDashboard.realised')}: ${formatCurrency(payload[0].payload.realised)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-purple-600 dark:text-purple-400 text-sm">
|
||||||
|
{`${t('teamDashboard.unrealised')}: ${formatCurrency(payload[0].payload.unrealised)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-blue-600 dark:text-blue-400 text-sm font-semibold mt-1">
|
||||||
|
{`${t('teamDashboard.total')}: ${formatCurrency(payload[0].payload.total)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="realised"
|
||||||
|
stackId="a"
|
||||||
|
fill="#10b981"
|
||||||
|
name={t('teamDashboard.realised')}
|
||||||
|
radius={[0, 0, 0, 0]}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="unrealised"
|
||||||
|
stackId="a"
|
||||||
|
fill="#8b5cf6"
|
||||||
|
name={t('teamDashboard.unrealised')}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import DebugPolishOrthophotoMap from '../../components/ui/DebugPolishOrthophotoMap';
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user