Compare commits

...

118 Commits

Author SHA1 Message Date
05ec244107 feat: update createContractHandler to return the newly created contract with its ID 2025-12-03 22:22:26 +01:00
77f4c80a79 feat: add customer contract number to project view and update project query 2025-12-03 22:15:50 +01:00
5abacdc8e1 feat: update contract handling to include customer contract number and improve status filtering 2025-12-03 22:04:48 +01:00
5b794a59bc feat: enhance contact phone handling to support multiple numbers and improve form submission 2025-12-03 21:06:42 +01:00
9dd208d168 feat: redesign contacts list to use a table layout for improved readability and organization 2025-12-03 20:58:18 +01:00
02f31cb444 fix: update notes handling in contact migration to preserve original contact info 2025-12-03 20:46:27 +01:00
60b79fa360 feat: add contact management functionality
- Implemented ContactForm component for creating and editing contacts.
- Added ProjectContactSelector component to manage project-specific contacts.
- Updated ProjectForm to include ProjectContactSelector for associating contacts with projects.
- Enhanced Card component with a new CardTitle subcomponent for better structure.
- Updated Navigation to include a link to the contacts page.
- Added translations for contact-related terms in the i18n module.
- Initialized contacts database schema and created necessary tables for contact management.
- Developed queries for CRUD operations on contacts, including linking and unlinking contacts to projects.
- Created a test script to validate contact queries against the database.
2025-12-03 16:23:05 +01:00
c9b7355f3c fix: update cron job command to use the correct node path for backups 2025-12-03 08:01:02 +01:00
eb41814c24 feat: add settings table and backup notification functionality 2025-12-02 11:30:13 +01:00
e6fab5ba31 feat: add deployment guide, backup functionality, and cron jobs for automated backups 2025-12-02 11:11:47 +01:00
99853bb755 Merge branch 'ui-fix' of https://git.wastpol.pl/admin/Panel into ui-fix 2025-12-02 11:06:34 +01:00
9b84c6b9e8 feat: implement notifications system with API endpoints for fetching and marking notifications as read 2025-12-02 11:06:31 +01:00
6ac5ac9dda feat: add documentation for files to delete from codebase, including debug, test, and one-off scripts 2025-11-20 21:37:21 +01:00
fae7615818 feat: add edited_at column to notes and implement note update functionality with audit logging 2025-11-18 09:43:39 +01:00
acb7117c7d feat: add internationalization support for TeamLeadsDashboard with translations for various labels and messages 2025-11-14 15:22:36 +01:00
1d8ee8b0ab feat: add year selection filter to TeamLeadsDashboard and update API request accordingly 2025-11-14 15:17:29 +01:00
d3fa4df621 feat: enhance dashboard API to generate continuous sample data and calculate cumulative values for project timelines 2025-11-14 15:06:36 +01:00
a1f1b33e44 feat: enhance TeamLeadsDashboard with detailed realised vs unrealised values by project type and improved chart visualization 2025-11-14 12:24:41 +01:00
7f63dc1df6 feat: add realised vs unrealised value summary to TeamLeadsDashboard with pie chart visualization 2025-11-14 12:08:46 +01:00
ac77a9d259 feat: enhance TeamLeadsDashboard with ComposedChart and improved tooltip for monthly and cumulative values 2025-11-14 11:17:29 +01:00
38b9401b04 feat: implement team lead dashboard with data fetching and chart display 2025-11-14 11:05:36 +01:00
9b1f42c4ec feat: add completion_date field to projects and update related functionalities in forms and queries 2025-11-14 10:01:49 +01:00
6b205f36bb refactor: simplify role checks for wartosc_zlecenia display in ProjectViewPage and ProjectForm 2025-11-14 09:41:06 +01:00
be1bab103f feat: update team_lead role checks for wartosc_zlecenia display in ProjectViewPage and ProjectForm 2025-11-14 09:33:27 +01:00
c2dbc9d777 feat: track wartosc_zlecenia in project updates and display in ProjectViewPage for team leads 2025-11-14 09:09:23 +01:00
3f87ea16f2 feat: add wartosc_zlecenia field to projects table and update related functionalities 2025-11-14 09:04:46 +01:00
056198ff16 feat: add team_lead role to valid roles in user creation and update handlers 2025-11-14 08:22:11 +01:00
5b1a284fc3 feat: add team_lead option to user role selection in EditUserPage 2025-11-14 08:16:05 +01:00
23b3c0e9e8 feat: add team_lead role to user management and update related functionalities 2025-11-14 08:08:01 +01:00
eec0c0a281 feat: add Geoportal link for project coordinates with EPSG:2180 projection 2025-11-05 11:21:00 +01:00
cc242d4e10 feat: load phoneOnly filter from localStorage after component mount 2025-10-27 10:52:03 +01:00
b6ceac6e38 feat: add Google Maps link for project coordinates display 2025-10-24 14:00:04 +02:00
42668862fd feat: add download functionality for upcoming projects report in Excel format 2025-10-22 11:38:19 +02:00
af28be8112 feat: preserve phone toggle state in filter reset and update active filter logic 2025-10-18 13:04:54 +02:00
27247477c9 feat: add phone number filter and related translations in project list 2025-10-17 16:00:44 +02:00
bd0345df1a feat: add locale support for date formatting in task components 2025-10-14 13:13:16 +02:00
a1b9c05673 feat: add 'Przypisany do' field to exported projects and update filename format 2025-10-14 13:07:33 +02:00
d9e559982a feat: hide appearance and language settings cards in the SettingsPage 2025-10-14 12:57:13 +02:00
0e237a9549 feat: add filter for user's assigned tasks and update translation for 'mine' 2025-10-14 12:55:28 +02:00
f1e7c2d7aa feat: implement password reset functionality with token verification and change password feature 2025-10-10 09:15:29 +02:00
7ec4bdf620 feat: update project titles in metadata and translations for consistency 2025-10-10 08:59:22 +02:00
ec5b60d478 feat: update ProjectListPage layout and button styles for improved UI/UX 2025-10-09 20:56:55 +02:00
ac5fedb61a feat: implement project export to Excel with API endpoint and UI button 2025-10-09 20:43:53 +02:00
ce3c53b4a8 feat: add 'can_be_assigned' column to users with default settings for admin users 2025-10-09 15:01:01 +02:00
cdfc37c273 feat: add 'can_be_assigned' field to users with updates to user creation, retrieval, and assignment queries 2025-10-09 14:55:11 +02:00
1288fe1cf8 feat: add 'not_started' task status with updates to task creation, status handling, and translations 2025-10-09 12:39:43 +02:00
33c5466d77 feat: implement 'not_started' task status with validation and UI updates 2025-10-07 23:07:15 +02:00
a6ef325813 feat: add task_category field to tasks with validation and update related forms 2025-10-07 22:22:10 +02:00
952caf10d1 feat: add task sets functionality with CRUD operations and UI integration
- Implemented NewTaskSetPage for creating task sets with templates.
- Created TaskSetsPage for listing and filtering task sets.
- Enhanced TaskTemplatesPage with navigation to task sets.
- Updated ProjectTaskForm to support task set selection.
- Modified PageHeader to support multiple action buttons.
- Initialized database with task_sets and task_set_templates tables.
- Added queries for task sets including creation, retrieval, and deletion.
- Implemented applyTaskSetToProject function for bulk task creation.
- Added test script for verifying task sets functionality.
2025-10-07 21:58:08 +02:00
e19172d2bb feat: Restrict notifications feature to admin users only in navigation 2025-10-06 16:07:17 +02:00
80a53d5d15 feat: Add notifications dropdown to navigation and update translations for notifications 2025-10-06 16:01:29 +02:00
5011f80fc4 feat: Add timezone configuration for Docker to ensure correct time handling in production and development environments 2025-10-04 20:52:39 +02:00
9357c2e0b9 Merge branch 'ui-fix' of https://git.wastpol.pl/admin/Panel into ui-fix 2025-10-04 19:44:39 +02:00
119b03a7ba feat: Update date handling to use local time formatting across various components and queries 2025-10-04 19:44:35 +02:00
f4b30c0faf feat: Update README.md with authentication, user management, and audit logging details 2025-10-02 19:35:31 +02:00
79238dd643 feat: Add link parsing functionality to notes in ProjectViewPage and TaskCommentsModal 2025-10-02 11:36:36 +02:00
31736ccc78 feat: Enhance FieldWithHistory component with improved tooltip display and time formatting 2025-10-02 10:01:54 +02:00
50760ab099 feat: Integrate translation support in FieldWithHistory component and update i18n strings 2025-10-02 09:57:51 +02:00
a59dc83678 feat: Add 'mine' filter to ProjectListPage and update translations 2025-09-29 22:07:18 +02:00
769fc73898 feat: Add ProjectAssigneeDropdown component and integrate it into ProjectViewPage 2025-09-29 20:09:11 +02:00
6ab87c7396 feat: Add assigned user initial to project details and update translations 2025-09-29 19:50:22 +02:00
a4e607bfe1 feat: Add migration script to add 'initial' column to users table 2025-09-29 19:44:36 +02:00
e589d6667f feat: Add initial identifier field to user management and update related functions 2025-09-29 19:41:49 +02:00
fc5f0fd39a feat: Enhance EditProjectPage with translation support and refactor ProjectForm to use forwardRef 2025-09-29 19:32:08 +02:00
e68b185aeb feat: Update task assignment logic to exclude admin users and enhance project page layout 2025-09-29 19:25:15 +02:00
5aac63dfde feat: Implement route planning feature with project selection and optimization
- Added route planning functionality to the map page, allowing users to select projects for routing.
- Implemented state management for route projects, start/end points, and search functionality.
- Integrated OpenRouteService API for route calculation and optimization.
- Enhanced UI with a route planning panel, including search and drag-and-drop reordering of projects.
- Added visual indicators for route start and end points on the map.
- Included translations for route planning features in both Polish and English.
- Created utility functions for route calculations, optimizations, and formatting of route data.
2025-09-26 00:18:10 +02:00
8a0baa02c3 fix: Refine theme initialization logic to respect saved preferences and system color scheme 2025-09-25 09:03:13 +02:00
fd87b66b06 feat: Implement dark mode support across components and UI elements
- Added dark mode styles to TaskStatusDropdown, TaskStatusDropdownDebug, and TaskStatusDropdownSimple components.
- Introduced ThemeProvider and useTheme hook for managing theme state.
- Updated Button, Card, Input, Loading, Navigation, PageContainer, PageHeader, ProjectCalendarWidget, ProjectMap, SearchBar, States, Tooltip, and other UI components to support dark mode.
- Created ThemeToggle component for switching between light and dark modes.
- Enhanced i18n translations for settings related to theme and language preferences.
- Configured Tailwind CSS to support dark mode with class-based toggling.
2025-09-25 08:58:03 +02:00
96333ecced feat: Implement file upload and management features in ProjectViewPage 2025-09-24 21:55:44 +02:00
0f451555d3 feat: Add script to create sample projects with detailed information 2025-09-24 21:20:05 +02:00
5193442e10 feat: Include project number in project details for enhanced information display 2025-09-19 13:52:20 +02:00
94b46be15b fix: Improve layout of project status indicators for better alignment and visibility 2025-09-19 07:40:33 +02:00
c39746f4f6 fix: Update shortLabel for in-progress construction status and improve project count display in ProjectsMapPage 2025-09-18 16:02:30 +02:00
671a4490d7 feat: Update SVG icons in ProjectViewPage and ProjectTasksSection for improved styling and consistency 2025-09-18 12:44:42 +02:00
e091e29a80 feat: Reorganize overdue projects section for improved visibility in project calendar 2025-09-18 12:37:47 +02:00
142b6490cc feat: Implement task template management with translation support and improved UI components 2025-09-18 12:36:03 +02:00
abfd174f85 fix: Update upcoming projects calculation to include the next 5 weeks 2025-09-18 12:01:52 +02:00
8964a9b29b feat: Enhance note creation and project cancellation with user information and translations 2025-09-18 11:11:22 +02:00
1a49919000 refactor: Optimize map component rendering and memoize functions to improve performance 2025-09-17 08:29:51 +02:00
0bb0b07429 feat: Add measurement tool with distance calculation and visual markers on the map 2025-09-16 16:41:08 +02:00
e4a4261a0e BUGFIX: Refactor map loading to use a dedicated loading component and improve dynamic import handling 2025-09-16 16:35:25 +02:00
029b091b10 feat: Add translation support for map-related components and improve loading messages 2025-09-16 16:32:04 +02:00
cf8ff874da feat: Update project and contract terminology for consistency in UI and translations 2025-09-16 14:44:01 +02:00
c75982818c feat: Enhance map controls and overlay visibility in project map components 2025-09-16 12:11:52 +02:00
e5e72b597a BUGFIX: Remove redundant GET function for file retrieval 2025-09-16 11:29:19 +02:00
06599c844a feat: Implement file upload structure and API for file retrieval 2025-09-16 11:27:25 +02:00
e5955a31fd feat: Integrate translation support for contract-related components and improve user feedback messages 2025-09-16 11:20:14 +02:00
43622f8e65 feat: Add comprehensive documentation for Route Planning feature with optimization details 2025-09-16 10:58:28 +02:00
7a2611f031 feat: Update page titles dynamically based on project name and standardize layout metadata 2025-09-16 10:57:25 +02:00
249b1e21c3 feat: Enhance project display with hoverable overflow for additional projects 2025-09-15 09:45:49 +02:00
551a0ea71a feat: Implement mobile-friendly filter toggle and enhance project list filters 2025-09-12 13:36:28 +02:00
adc348b61b refactor: Update user role display in navigation component to improve readability 2025-09-12 12:12:33 +02:00
49f97a9939 feat: Enhance navigation component with mobile menu support and improved styling 2025-09-12 12:03:07 +02:00
99f3d657ab feat: Update audit log queries to improve clarity and accuracy in statistics 2025-09-12 11:41:51 +02:00
cc6d217476 feat: Enhance project status handling with additional statuses and translations 2025-09-12 11:17:36 +02:00
47d730f192 feat: Add AUTH_TRUST_HOST environment variable to production Docker configuration 2025-09-12 11:08:06 +02:00
c1d49689da feat: Enhance deployment scripts with environment variable validation and loading 2025-09-12 09:21:53 +02:00
95ef139843 feat: Add support for project cancellation status across the application 2025-09-11 16:19:46 +02:00
2735d46552 feat: Add finish date update tracking for projects 2025-09-11 15:50:48 +02:00
0dd988730f feat: Implement internationalization for task management components
- Added translation support for task-related strings in ProjectTaskForm and ProjectTasksSection components.
- Integrated translation for navigation items in the Navigation component.
- Created ProjectCalendarWidget component with Polish translations for project statuses and deadlines.
- Developed Tooltip component for enhanced user experience with tooltips.
- Established a field change history logging system in the database with associated queries.
- Enhanced task update logging to include translated status and priority changes.
- Introduced server-side translations for system messages to improve localization.
2025-09-11 15:49:07 +02:00
50adc50a24 feat: Update .gitignore to include /kosz and /public/uploads directories 2025-07-30 12:46:05 +02:00
639a7b7eab feat: Implement file upload and management system with database integration 2025-07-30 11:37:25 +02:00
07b4af5f24 feat: Refactor user management to replace email with username across the application 2025-07-28 22:25:23 +02:00
6fc2e6703b feat: Implement redirect to projects page on home component load 2025-07-28 22:08:28 +02:00
764f6d1100 feat: Update Docker entrypoint scripts to create admin account on container startup 2025-07-28 22:00:47 +02:00
225d16c1c9 feat: Update Docker deployment scripts and configurations for default admin account creation 2025-07-28 21:55:11 +02:00
aada481c0a feat: Automatically create default admin account during Docker build process 2025-07-28 21:48:28 +02:00
c767e65819 fix: Update port mapping to avoid conflict in production and development configurations 2025-07-28 21:44:02 +02:00
8e35821344 Refactor project tasks page and navigation components
- Updated the description in ProjectTasksPage to a placeholder.
- Commented out the display of assigned email in ProjectTasksList.
- Removed the dashboard link from the navigation items.
- Changed the main link in the navigation to point to projects instead of the dashboard.
- Commented out the LanguageSwitcher and user role display in the navigation.
- Translated "Project Location" to "Lokalizacja projektu" in ProjectMap.
- Commented out the instruction for using the layer control in ProjectMap.
- Removed the label "Coordinates:" from the coordinates display in ProjectMap.
- Updated project and contract subtitles in translations to placeholders.
- Added a new empty validation schema file.
2025-07-28 20:56:04 +02:00
Chop
747a68832e feat(i18n): Add Polish translations for task management components and update search placeholder 2025-07-27 23:54:56 +02:00
Chop
e828aa660b feat(i18n): Implement multilingual support with Polish and English translations
- Added translation context and provider for managing language state.
- Integrated translation functionality into existing components (TaskStatusDropdown, Navigation).
- Created LanguageSwitcher component for language selection.
- Updated task statuses and navigation labels to use translations.
- Added Polish translations for various UI elements, including navigation, tasks, projects, and contracts.
- Refactored utility functions to return localized strings for deadlines and date formatting.
2025-07-27 22:01:15 +02:00
Chop
9b6307eabe feat: Add TaskCommentsModal for viewing and managing task comments 2025-07-17 22:49:12 +02:00
Chop
490994d323 feat: Add optional max wait display to task rows and tables 2025-07-17 22:39:54 +02:00
Chop
b5120657a9 refactor: Update layout of task template actions for improved alignment and usability 2025-07-10 23:45:50 +02:00
Chop
5228ed3fc0 feat: Enhance ProjectTasksSection with improved button styles and layout adjustments 2025-07-10 23:41:03 +02:00
Chop
51d37fc65a feat: Implement task editing functionality with validation and user assignment 2025-07-10 23:35:17 +02:00
Chop
92f458e59b fix: Update note rendering logic to simplify system note display 2025-07-10 23:02:45 +02:00
Chop
33ea8de17e feat: Add coordinate formatting utility and update project views 2025-07-10 22:56:03 +02:00
170 changed files with 21867 additions and 2675 deletions

7
.gitignore vendored
View File

@@ -44,4 +44,9 @@ next-env.d.ts
/data /data
# kosz # kosz
/kosz /kosz
# uploads
/public/uploads
/backups

174
CONTACTS_SYSTEM_README.md Normal file
View 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! 🎉

View File

@@ -0,0 +1,410 @@
# Docker Git Deployment Strategy - Quick Guide
A proven deployment strategy for Next.js apps (or any Node.js app) on a VPS using Docker with Git integration.
## Quick Overview
**Strategy**: Docker containers + Git repo integration + Zero-downtime deployments
**Benefits**: Reproducible builds, easy rollbacks, consistent environments
**Time to setup**: ~30 minutes
---
## Step 1: Create Docker Files
### `Dockerfile` (Production)
```dockerfile
FROM node:22.11.0
# Set timezone (adjust for your region)
ENV TZ=Europe/Warsaw
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# Install git for repo cloning
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Support building from Git repo
ARG GIT_REPO_URL
ARG GIT_BRANCH=main
ARG GIT_COMMIT
# Clone from git OR use 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 ./
RUN npm install
COPY . .
RUN npm run build
# Copy entrypoint script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["/docker-entrypoint.sh"]
```
### `docker-entrypoint.sh`
```bash
#!/bin/bash
echo "🚀 Starting application..."
# Create necessary directories
mkdir -p /app/data
mkdir -p /app/public/uploads
# Initialize database, create admin, etc.
node scripts/init-setup.js
# Start the app
exec npm start
```
### `docker-compose.prod.yml`
```yaml
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" # HOST:CONTAINER
volumes:
- ./data:/app/data # Persist database
- ./uploads:/app/public/uploads # Persist files
environment:
- NODE_ENV=production
- TZ=Europe/Warsaw
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=${NEXTAUTH_URL}
- AUTH_TRUST_HOST=true
restart: unless-stopped
```
---
## Step 2: Create Deployment Script
### `deploy.sh` (Linux/Mac)
```bash
#!/bin/bash
set -e
GIT_REPO_URL=${1:-""}
GIT_BRANCH=${2:-"main"}
GIT_COMMIT=${3:-""}
# Load environment variables
if [ -f .env.production ]; then
export $(grep -v '^#' .env.production | xargs)
fi
# Validate critical vars
if [ -z "$NEXTAUTH_SECRET" ] || [ -z "$NEXTAUTH_URL" ]; then
echo "ERROR: Set NEXTAUTH_SECRET and NEXTAUTH_URL in .env.production"
exit 1
fi
# Build from Git or local files
if [ -z "$GIT_REPO_URL" ]; then
echo "Building from local files..."
docker-compose -f docker-compose.prod.yml build
else
echo "Building from git: $GIT_REPO_URL (branch: $GIT_BRANCH)"
GIT_REPO_URL=$GIT_REPO_URL GIT_BRANCH=$GIT_BRANCH GIT_COMMIT=$GIT_COMMIT \
docker-compose -f docker-compose.prod.yml build
fi
# Deploy
echo "Deploying..."
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
echo "✅ Deployment completed!"
echo "Application running at: $NEXTAUTH_URL (port 3001 on host)"
```
Make it executable:
```bash
chmod +x deploy.sh
```
---
## Step 3: Configure Environment
### `.env.production`
```bash
# Generate secret: openssl rand -base64 32
NEXTAUTH_SECRET=your-super-long-random-secret-at-least-32-chars
# Your public URL
NEXTAUTH_URL=https://yourdomain.com
NODE_ENV=production
AUTH_TRUST_HOST=true
```
**⚠️ NEVER commit `.env.production` to Git!**
---
## Step 4: Setup VPS
### Initial VPS Setup
```bash
# SSH to VPS
ssh user@your-vps-ip
# Install Docker & Docker Compose
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
sudo apt install docker-compose-plugin
# Logout and login again for docker group to take effect
exit
```
### Setup Project Directory
```bash
ssh user@your-vps-ip
# Create project directory
mkdir -p ~/app
cd ~/app
# Copy deployment files (from local machine):
# scp docker-compose.prod.yml deploy.sh user@vps-ip:~/app/
# scp .env.production user@vps-ip:~/app/
# OR clone entire repo if using local file deployment:
git clone https://your-repo-url.git .
```
---
## Step 5: Setup Nginx Reverse Proxy
### Install Nginx
```bash
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx
```
### Configure Nginx
Create `/etc/nginx/sites-available/yourapp`:
```nginx
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable site:
```bash
sudo ln -s /etc/nginx/sites-available/yourapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
### Setup SSL (HTTPS)
```bash
sudo certbot --nginx -d yourdomain.com
# Follow prompts to get free SSL certificate
```
---
## Step 6: Deploy!
### Deployment Methods
**Method 1: From Local Files**
```bash
cd ~/app
git pull origin main # Update code
./deploy.sh
```
**Method 2: From Git Repository**
```bash
cd ~/app
./deploy.sh https://git.yourserver.com/user/repo.git main
```
**Method 3: Specific Commit**
```bash
./deploy.sh https://git.yourserver.com/user/repo.git main abc123def
```
---
## Ongoing Maintenance
### View Logs
```bash
docker-compose -f docker-compose.prod.yml logs -f
```
### Restart App
```bash
docker-compose -f docker-compose.prod.yml restart
```
### Full Rebuild (for Dockerfile changes)
```bash
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml build --no-cache
docker-compose -f docker-compose.prod.yml up -d
```
### Check Container Status
```bash
docker-compose -f docker-compose.prod.yml ps
docker-compose -f docker-compose.prod.yml exec app date # Check timezone
```
### Backup Data
```bash
# Automated daily database backups are scheduled at 2 AM
# Backups are stored in ./backups/ directory, keeping last 30
# Check backup logs: docker-compose -f docker-compose.prod.yml exec app cat /app/data/backup.log
# Manual backup (if needed)
tar -czf backup-$(date +%Y%m%d).tar.gz data/ uploads/
# Download backups to local machine
scp user@vps-ip:~/app/backups/backup-*.sqlite ./local-backups/
```
### Rollback to Previous Version
```bash
# If using Git commits
./deploy.sh https://git.yourserver.com/user/repo.git main PREVIOUS_COMMIT_HASH
```
---
## Troubleshooting
### Container won't start
```bash
docker-compose -f docker-compose.prod.yml logs app
docker-compose -f docker-compose.prod.yml exec app sh # Get shell inside container
```
### Timezone issues
- Make sure `TZ` env var is set in docker-compose
- Rebuild image: timezone config is baked in during build
- Verify: `docker-compose -f docker-compose.prod.yml exec app date`
### Permission issues with volumes
```bash
# Fix ownership
sudo chown -R $USER:$USER data/ uploads/
```
### Port already in use
```bash
# Check what's using port 3001
sudo netstat -tulpn | grep 3001
# Change port in docker-compose.prod.yml if needed
```
---
## Security Checklist
- [ ] Strong `NEXTAUTH_SECRET` generated (min 32 chars)
- [ ] `.env.production` has secure permissions: `chmod 600 .env.production`
- [ ] Firewall configured: `sudo ufw allow 80,443/tcp`
- [ ] SSL certificate installed via Certbot
- [ ] Regular security updates: `sudo apt update && sudo apt upgrade`
- [ ] Docker images updated periodically
- [ ] Database backups automated
- [ ] Git credentials NOT stored in environment files
---
## Quick Reference
```bash
# Deploy from Git
./deploy.sh https://git.server.com/user/repo.git main
# Deploy from local files
git pull && ./deploy.sh
# View logs
docker-compose -f docker-compose.prod.yml logs -f
# Restart
docker-compose -f docker-compose.prod.yml restart
# Rebuild completely
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml build --no-cache
docker-compose -f docker-compose.prod.yml up -d
# Backup
tar -czf backup-$(date +%Y%m%d).tar.gz data/ uploads/
```
---
## Key Advantages of This Strategy
**Git Integration**: Deploy specific commits, branches, or tags
**Reproducible**: Same build every time
**Easy Rollbacks**: Just deploy previous commit
**Isolated**: Container doesn't pollute host system
**Persistent Data**: Volumes survive container rebuilds
**Zero-Config Deployment**: Clone and run `./deploy.sh`
**Works Offline**: Can build from local files without Git
**Auto-Restart**: Container restarts on crash or reboot
---
## Notes to Future Self
1. **Always use volumes** for data persistence (database, uploads)
2. **Timezone matters**: Set it in both Dockerfile and docker-compose
3. **Rebuild vs Restart**: Dockerfile changes need rebuild, code changes just restart
4. **Port mapping**: Be consistent (I use 3001:3000 - HOST:CONTAINER)
5. **Environment secrets**: Never commit, always use `.env.production`
6. **Nginx**: Don't forget to setup reverse proxy and SSL
7. **Git auth**: For private repos, use SSH keys or tokens in URL
8. **Test locally first**: Use `docker-compose.yml` with `Dockerfile.dev`
9. **Monitor logs**: Set up log rotation if app is chatty
10. **Automate backups**: Cron job for daily database/file backups
---
**Time to deploy a new app with this strategy: ~20 minutes**
Copy these files, adjust for your app (mainly environment variables and init scripts), and you're production-ready!

129
DEPLOYMENT_TIMEZONE_FIX.md Normal file
View File

@@ -0,0 +1,129 @@
# Quick Deployment Guide - Timezone Fix
## For Production Server
1. **SSH into your server** where Docker is running
2. **Navigate to project directory**
```bash
cd /path/to/panel
```
3. **Pull latest code** (includes timezone fixes)
```bash
git pull origin main
```
4. **Stop running containers**
```bash
docker-compose -f docker-compose.prod.yml down
```
5. **Rebuild Docker images** (this is critical - it bakes in the timezone configuration)
```bash
docker-compose -f docker-compose.prod.yml build --no-cache
```
6. **Start containers**
```bash
docker-compose -f docker-compose.prod.yml up -d
```
7. **Verify timezone is correct**
```bash
# Check container timezone
docker-compose -f docker-compose.prod.yml exec app date
# Should show Polish time with CEST/CET timezone
# Example output:
# Sat Oct 4 19:45:00 CEST 2025
```
8. **Test the fix**
- Post a new note at a known time (e.g., 19:45)
- Verify it displays the same time (19:45)
- Test both project notes and task notes
## What Changed
### Code Changes
- ✅ Fixed `datetime('now', 'localtime')` in all database queries
- ✅ Updated display formatters to use Europe/Warsaw timezone
- ✅ Fixed note display in components
### Docker Changes (Critical!)
- ✅ Set `ENV TZ=Europe/Warsaw` in Dockerfile
- ✅ Configured system timezone in containers
- ✅ Added TZ environment variable to docker-compose files
## Why Rebuild is Necessary
The timezone configuration is **baked into the Docker image** during build time:
- `ENV TZ=Europe/Warsaw` - Set during image build
- `RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime` - Executed during image build
Just restarting containers (`docker-compose restart`) will **NOT** apply these changes!
## Troubleshooting
### If times are still wrong after deployment:
1. **Verify you rebuilt the images**
```bash
docker images | grep panel
# Check the "CREATED" timestamp - should be recent
```
2. **Check if container has correct timezone**
```bash
docker-compose -f docker-compose.prod.yml exec app date
```
Should show Polish time, not UTC!
3. **Check SQLite is using correct time**
```bash
docker-compose -f docker-compose.prod.yml exec app node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime') as time\").get());"
```
Should show current Polish time
4. **Force rebuild if needed**
```bash
docker-compose -f docker-compose.prod.yml down
docker system prune -f
docker-compose -f docker-compose.prod.yml build --no-cache
docker-compose -f docker-compose.prod.yml up -d
```
## Expected Behavior After Fix
### Before Fix (Docker in UTC):
```
User posts note at 10:30 Poland time
→ Docker sees 08:30 UTC as "local time"
→ SQLite stores: 08:30
→ Display shows: 08:30 ❌ (2 hours off!)
```
### After Fix (Docker in Europe/Warsaw):
```
User posts note at 10:30 Poland time
→ Docker sees 10:30 Poland time as "local time"
→ SQLite stores: 10:30
→ Display shows: 10:30 ✅ (correct!)
```
## Important Notes
1. **Old notes**: Notes created before this fix may still show incorrect times (they were stored in UTC)
2. **New notes**: All new notes after deployment will show correct times
3. **Audit logs**: Continue to work correctly (they always used ISO format)
4. **Zero downtime**: Can't achieve - need to stop/rebuild/start containers
## Quick Check Command
After deployment, run this one-liner to verify everything:
```bash
docker-compose -f docker-compose.prod.yml exec app sh -c 'date && node -e "console.log(new Date().toLocaleString(\"pl-PL\"))"'
```
Both outputs should show the same Polish time!

204
DOCKER_GIT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,204 @@
# Docker Git Deployment Guide
This project now supports deploying directly from a Git repository using Docker. This is useful for automated deployments and CI/CD pipelines.
## File Structure
- `Dockerfile` - Production dockerfile that supports Git deployment
- `Dockerfile.dev` - Development dockerfile
- `docker-compose.yml` - Development environment
- `docker-compose.prod.yml` - Production environment with Git support
- `deploy.sh` / `deploy.bat` - Deployment scripts
## Deployment Options
### 1. Deploy from Local Files (Default)
```bash
# Development
docker-compose up
# Production
docker-compose -f docker-compose.prod.yml up --build
```
**Note**: Both development and production Docker builds automatically:
- Create the default admin account
- Run any pending database migrations
- Initialize/update the database schema
### 2. Deploy from Git Repository
#### Using Environment Variables
Create a `.env` file with:
```env
GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
GIT_BRANCH=ui-fix
GIT_COMMIT=abc123 # Optional: specific commit hash
```
Then run:
```bash
docker-compose -f docker-compose.prod.yml up --build
```
#### Using Build Arguments
```bash
docker build \
--build-arg GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git \
--build-arg GIT_BRANCH=ui-fix \
--build-arg GIT_COMMIT=abc123 \
-t your-app .
```
#### Using Deployment Scripts
```bash
# Linux/Mac
./deploy.sh https://git.wastpol.pl/Admin/panel.git ui-fix abc123
# Windows
deploy.bat https://git.wastpol.pl/Admin/panel.git ui-fix abc123
```
## Private Repositories
For private repositories, you have several options:
### 1. SSH Keys (Recommended for development)
```bash
# Build with SSH URL
docker build --build-arg GIT_REPO_URL=git@git.wastpol.pl:Admin/panel.git .
```
### 2. Personal Access Token
```bash
# Build with token in URL
docker build --build-arg GIT_REPO_URL=https://username:token@git.wastpol.pl/Admin/panel.git .
```
### 3. Docker Secrets (Recommended for production)
```yaml
# In docker-compose.prod.yml
services:
app:
build:
context: .
args:
- GIT_REPO_URL=https://git.wastpol.pl/Admin/panel.git
secrets:
- git_token
secrets:
git_token:
file: ./git_token.txt
```
## CI/CD Integration
### GitHub Actions Example
```yaml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to server
run: |
docker build \
--build-arg GIT_REPO_URL=${{ github.repository }} \
--build-arg GIT_COMMIT=${{ github.sha }} \
-t my-app .
docker run -d -p 3000:3000 my-app
```
### Docker Compose in CI/CD
```bash
# Set environment variables in your CI/CD system
export GIT_REPO_URL="https://git.wastpol.pl/Admin/panel.git"
export GIT_BRANCH="ui-fix"
export GIT_COMMIT="$CI_COMMIT_SHA"
# Deploy
docker-compose -f docker-compose.prod.yml up --build -d
```
## Build Process
When `GIT_REPO_URL` is provided:
1. Git repository is cloned into the container
2. If `GIT_COMMIT` is specified, checkout that specific commit
3. Install dependencies from the repository's package.json
4. Build the application
5. **Admin account is created when container starts (not during build)**
6. Start the production server
When `GIT_REPO_URL` is not provided:
1. Copy local files into the container
2. Install dependencies
3. Build the application
4. **Admin account is created when container starts (not during build)**
5. Start the production server
## Default Admin Account
Both development and production Docker containers automatically create a default admin account **when the container starts** (not during build). This ensures the database is properly persisted in mounted volumes.
- **Email**: `admin@localhost.com`
- **Password**: `admin123456`
- **Role**: `admin`
⚠️ **Important Security Note**: Please change the default password immediately after your first login!
### Manual Admin Account Creation
If you need to create the admin account manually (for development or testing):
```bash
# Using npm script
npm run create-admin
# Or directly
node scripts/create-admin.js
```
The script will skip creation if an admin account already exists.
## Environment Variables
- `GIT_REPO_URL` - Git repository URL (HTTPS or SSH)
- `GIT_BRANCH` - Git branch to checkout (default: main)
- `GIT_COMMIT` - Specific commit hash to checkout (optional)
- `NODE_ENV` - Node.js environment (development/production)
## Troubleshooting
### Git Authentication Issues
- Ensure your Git credentials are properly configured
- For HTTPS, use personal access tokens instead of passwords
- For SSH, ensure SSH keys are properly mounted or available
### Build Failures
- Check if the repository URL is accessible
- Verify the branch name exists
- Ensure the commit hash is valid
- Check Docker build logs for specific errors
### Permission Issues
- Ensure the Docker daemon has network access
- For private repositories, verify authentication tokens/keys
### Admin Account Issues
- If admin creation fails during startup, check database initialization
- Ensure the `./data` directory is writable on the host
- Database files are persisted in the mounted `./data` volume
- Admin account is created on container startup, not during build
- If admin already exists, the script will skip creation (this is normal)
- To recreate admin account, delete the database file in `./data/` and restart the container

156
DOCKER_TIMEZONE_FIX.md Normal file
View File

@@ -0,0 +1,156 @@
# Docker Timezone Configuration Fix
## Problem
Even after fixing the SQLite `datetime('now', 'localtime')` calls, notes posted at 10:00 still showed as 08:00 when running in Docker.
## Root Cause
**Docker containers run in UTC timezone by default!**
When using `datetime('now', 'localtime')` in SQLite:
- On local Windows machine: Uses Windows timezone (Europe/Warsaw) → ✅ Correct
- In Docker container: Uses container timezone (UTC) → ❌ Wrong by 2 hours
Example:
```
User posts at 10:00 Poland time (UTC+2)
Docker container thinks local time is 08:00 UTC
SQLite datetime('now', 'localtime') stores: 08:00
Display shows: 08:00 (wrong!)
```
## Solution
Set the Docker container timezone to Europe/Warsaw
### 1. Updated Dockerfile (Production)
```dockerfile
# 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
# ... rest of Dockerfile
```
### 2. Updated Dockerfile.dev (Development)
```dockerfile
# 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
# ... rest of Dockerfile
```
### 3. Updated docker-compose.yml (Development)
```yaml
environment:
- NODE_ENV=development
- TZ=Europe/Warsaw
```
### 4. Updated docker-compose.prod.yml (Production)
```yaml
environment:
- NODE_ENV=production
- TZ=Europe/Warsaw
- NEXTAUTH_SECRET=...
- NEXTAUTH_URL=...
```
## How to Apply
### Option 1: Rebuild Docker Images
```bash
# Stop containers
docker-compose down
# Rebuild images
docker-compose build --no-cache
# Start containers
docker-compose up -d
```
### Option 2: For Production Deployment
```bash
# Pull latest code with fixes
git pull
# Rebuild production image
docker-compose -f docker-compose.prod.yml build --no-cache
# Restart
docker-compose -f docker-compose.prod.yml up -d
```
## Verification
After rebuilding and restarting, verify timezone inside container:
```bash
# Check timezone
docker exec -it <container_name> date
# Should show: Sat Oct 4 19:00:00 CEST 2025
# Check Node.js sees correct timezone
docker exec -it <container_name> node -e "console.log(new Date().toLocaleString('pl-PL', {timeZone: 'Europe/Warsaw'}))"
# Should show current Polish time
# Check SQLite sees correct timezone
docker exec -it <container_name> node -e "const db = require('better-sqlite3')('./data/database.sqlite'); console.log(db.prepare(\"SELECT datetime('now', 'localtime')\").get());"
# Should show current Polish time
```
## Why This Works
1. **TZ Environment Variable**: Tells all processes (including Node.js and SQLite) what timezone to use
2. **Symlink /etc/localtime**: Updates system timezone for the entire container
3. **echo TZ > /etc/timezone**: Ensures the timezone persists
Now when SQLite uses `datetime('now', 'localtime')`:
- Container local time is 10:00 Poland time
- SQLite stores: 10:00
- Display shows: 10:00 ✅
## Important Notes
1. **Must rebuild images**: Just restarting containers is not enough - the timezone configuration is baked into the image
2. **All existing data**: Old notes will still show incorrect times (they were stored in UTC)
3. **New notes**: Will now display correctly
4. **DST handling**: Europe/Warsaw automatically handles Daylight Saving Time transitions
## Alternative Approach (Not Recommended)
Instead of changing container timezone, you could:
1. Store everything in UTC (like audit logs do with ISO format)
2. Always convert on display
But this requires more code changes and the current approach is simpler and more maintainable.
## Files Modified
1. `Dockerfile` - Added TZ configuration
2. `Dockerfile.dev` - Added TZ configuration
3. `docker-compose.yml` - Added TZ environment variable
4. `docker-compose.prod.yml` - Added TZ environment variable
## Testing Checklist
After deployment:
- [ ] Container shows correct date/time with `docker exec <container> date`
- [ ] Post a new note at known time (e.g., 10:30)
- [ ] Verify note displays the same time (10:30)
- [ ] Check both project notes and task notes
- [ ] Verify audit logs still work correctly
- [ ] Check task timestamps (date_started, date_completed)

View File

@@ -1,20 +1,47 @@
# 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 . .
# 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
# 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
View 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"]

View File

@@ -32,13 +32,43 @@ A comprehensive project management system built with Next.js for managing constr
- Task completion tracking - Task completion tracking
- Quick access to pending items - Quick access to pending items
### 🔐 Authentication & Authorization
- Complete user authentication system with NextAuth.js
- Role-based access control (Admin, User, Guest roles)
- Secure session management
- Password-based authentication
- User registration and management
### 👥 User Management
- Admin interface for user administration
- Create, edit, and delete user accounts
- Role assignment and permission management
- User activity monitoring
### 📋 Audit Logging
- Comprehensive logging of all user actions
- Security event tracking
- System activity monitoring
- Audit trail for compliance and debugging
### 📊 Data Export
- Export projects to Excel format grouped by status
- Includes project name, address, plot, WP, and finish date
- Separate sheets for each project status (registered, in progress, fulfilled, etc.)
## Tech Stack ## Tech Stack
- **Framework**: Next.js 15.1.8 - **Framework**: Next.js 15.1.8
- **Database**: SQLite with better-sqlite3 - **Database**: SQLite with better-sqlite3
- **Authentication**: NextAuth.js
- **Styling**: Tailwind CSS - **Styling**: Tailwind CSS
- **Date Handling**: date-fns - **Date Handling**: date-fns
- **Frontend**: React 19 - **Frontend**: React 19
- **Mapping**: Leaflet with React-Leaflet
- **Container**: Docker & Docker Compose - **Container**: Docker & Docker Compose
## Getting Started ## Getting Started
@@ -95,6 +125,16 @@ docker-compose up
The application uses SQLite database which will be automatically initialized on first run. The database file is located at `data/database.sqlite`. The application uses SQLite database which will be automatically initialized on first run. The database file is located at `data/database.sqlite`.
### Admin Setup
To create an initial admin user:
```bash
npm run create-admin
```
This will create an admin user account. Access the admin panel at `/admin/users` to manage users and roles.
## Project Structure ## Project Structure
``` ```
@@ -144,6 +184,7 @@ src/
- `npm run build` - Build for production - `npm run build` - Build for production
- `npm run start` - Start production server - `npm run start` - Start production server
- `npm run lint` - Run ESLint - `npm run lint` - Run ESLint
- `npm run export-projects` - Export all projects to Excel file grouped by status
## Docker Commands ## Docker Commands
@@ -219,12 +260,25 @@ The application uses the following main tables:
## Advanced Map Features ## Advanced Map Features
This project includes a powerful map system for project locations, supporting multiple dynamic base layers: This project includes a powerful map system for project locations, supporting multiple dynamic base layers and transparent overlays:
### Base Layers (8 total)
- **OpenStreetMap** (default street map) - **OpenStreetMap** (default street map)
- **Polish Geoportal Orthophoto** (aerial imagery via WMTS) - **🇵🇱 Polish Orthophoto (Standard Resolution)** - WMTS aerial imagery
- **Polish Land Records** (WMS cadastral data) - **🇵🇱 Polish Orthophoto (High Resolution)** - WMTS high-res aerial imagery
- **Satellite (Esri)** and **Topographic** layers - **🌍 Google Satellite** - Global satellite imagery
- **🌍 Google Hybrid** - Satellite imagery with road overlays
- **🌍 Google Roads** - Google Maps road view
- **Satellite (Esri)** - Alternative satellite imagery
- **Topographic** - CartoDB topographic maps
### Overlay Layers (6 total with transparency)
- **📋 Polish Cadastral Data** (WMS, property boundaries - 80% opacity)
- **🏗️ Polish Spatial Planning** (WMS, zoning data - 70% opacity)
- **🛣️ LP-Portal Roads** (WMS, road network - 90% opacity)
- **🏷️ LP-Portal Street Names** (WMS, street labels - 100% opacity)
- **📐 LP-Portal Parcels** (WMS, property parcels - 60% opacity)
- **📍 LP-Portal Survey Markers** (WMS, survey points - 80% opacity)
Users can switch layers using the map control (📚 icon). WMTS/WMS layers are configured dynamically using OGC GetCapabilities, making it easy to add new sources. See [`docs/MAP_LAYERS.md`](docs/MAP_LAYERS.md) for details on adding and configuring map layers. Users can switch layers using the map control (📚 icon). WMTS/WMS layers are configured dynamically using OGC GetCapabilities, making it easy to add new sources. See [`docs/MAP_LAYERS.md`](docs/MAP_LAYERS.md) for details on adding and configuring map layers.

View File

@@ -16,59 +16,43 @@ This is a solid Next.js-based project management system for construction/enginee
- **API Structure**: RESTful API endpoints for all entities - **API Structure**: RESTful API endpoints for all entities
- **Docker Support**: Containerized development and deployment - **Docker Support**: Containerized development and deployment
- **Testing Setup**: Jest, Playwright, Testing Library configured - **Testing Setup**: Jest, Playwright, Testing Library configured
- **Authentication & Authorization**: NextAuth.js with role-based access control, user management UI, session management
- **Security Features**: Input validation with Zod, password hashing with bcryptjs, audit logging
- **Reporting Libraries**: Recharts for charts, jsPDF/jspdf-autotable for PDF, exceljs/xlsx for Excel export
- **Search & Filtering**: Basic search functionality implemented
--- ---
## Critical Missing Features for App ## Critical Missing Features for App
### 🔐 **1. Authentication & Authorization (HIGH PRIORITY)** ### <EFBFBD> **1. Security & Data Protection (HIGH PRIORITY)**
**Current State**: No authentication system **Current State**: Partial security measures implemented (Zod validation, bcrypt hashing, audit logging)
**Required**: **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 - CSRF protection
- Rate limiting - Rate limiting
- Environment variable security - Environment variable security
- Data encryption for sensitive fields - Data encryption for sensitive fields
- Audit logging - XSS protection (additional measures)
- Security headers middleware
- Comprehensive error handling
### 📊 **3. Advanced Reporting & Analytics (MEDIUM PRIORITY)** ### 📊 **2. Advanced Reporting & Analytics (MEDIUM PRIORITY)**
**Current State**: Basic dashboard statistics **Current State**: Libraries installed (Recharts, jsPDF, exceljs), basic dashboard statistics, API endpoints for reports
**Required**: **Required**:
- Project timeline reports - Full UI for project timeline reports
- Budget tracking and financial reports - Budget tracking and financial reports
- Task completion analytics - Task completion analytics
- Project performance metrics - Project performance metrics
- Export to PDF/Excel
- Custom report builder - Custom report builder
- Charts and graphs (Chart.js, D3.js) - Charts and graphs integration in UI
### 💾 **4. Backup & Data Management (HIGH PRIORITY)** ### 💾 **3. Backup & Data Management (HIGH PRIORITY)**
**Current State**: Single SQLite file **Current State**: Single SQLite file, manual export scripts
**Required**: **Required**:
- Automated database backups - Automated database backups
@@ -77,6 +61,122 @@ This is a solid Next.js-based project management system for construction/enginee
- Data archiving for old projects - Data archiving for old projects
- Recovery procedures - Recovery procedures
### 📱 **4. 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
### 🔗 **5. 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
### <20> **6. 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
### 📋 **7. 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
### 🔍 **8. 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
### ⚡ **9. 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
### 📝 **10. Documentation & Help System (LOW PRIORITY)**
**Current State**: README.md only
**Required**:
- User manual/documentation
- In-app help system
- API documentation
- Video tutorials
- FAQ section
### 🧪 **11. Testing & Quality Assurance (MEDIUM PRIORITY)**
**Current State**: Testing frameworks set up but minimal actual 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
### <20> **12. 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
### 🎨 **13. 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
### 📱 **5. Mobile Responsiveness & PWA (MEDIUM PRIORITY)** ### 📱 **5. Mobile Responsiveness & PWA (MEDIUM PRIORITY)**
**Current State**: Basic responsive design **Current State**: Basic responsive design
@@ -197,18 +297,18 @@ This is a solid Next.js-based project management system for construction/enginee
## Implementation Priority Levels ## Implementation Priority Levels
### Phase 1: Security & Stability (Weeks 1-4) ### Phase 1: Security Completion & Backup (Weeks 1-4)
1. Authentication system 1. Complete security measures (CSRF protection, rate limiting, security headers)
2. Authorization and role management 2. Backup system implementation
3. Input validation and security 3. Password reset functionality
4. Backup system 4. Enhanced error handling
5. Basic testing coverage 5. Basic testing coverage
### Phase 2: Core Features (Weeks 5-8) ### Phase 2: Core Features (Weeks 5-8)
1. Advanced reporting 1. Advanced reporting UI
2. Mobile optimization 2. Mobile optimization & PWA
3. Notification system 3. Notification system
4. Enhanced project management features 4. Enhanced project management features
@@ -230,34 +330,36 @@ This is a solid Next.js-based project management system for construction/enginee
## Immediate Next Steps (Recommended Order) ## Immediate Next Steps (Recommended Order)
1. **Set up Authentication** 1. **Complete Security Measures**
- Install NextAuth.js or implement custom auth - Implement CSRF protection
- Create user management system - Add rate limiting
- Add login/logout functionality - Set up security headers middleware
- Enhance error handling
2. **Implement Input Validation** 2. **Create Backup System**
- Add Zod or Joi for schema validation
- Protect all API endpoints
- Add error handling
3. **Create Backup System**
- Implement database backup scripts - Implement database backup scripts
- Set up automated backups - Set up automated backups
- Create recovery procedures - Create recovery procedures
3. **Implement Password Reset**
- Add password reset functionality
- Email templates and sending
- Secure token generation
4. **Add Basic Tests** 4. **Add Basic Tests**
- Write unit tests for critical functions - Write unit tests for critical functions
- Add integration tests for API routes - Add integration tests for API routes
- Set up test automation - Set up test automation
5. **Implement Reporting** 5. **Build Advanced Reporting UI**
- Add Chart.js for visualizations
- Create project timeline reports - Create project timeline reports page
- Add export functionality - Integrate charts with Recharts
- Add PDF/Excel export UI
--- ---
@@ -265,25 +367,25 @@ This is a solid Next.js-based project management system for construction/enginee
### Authentication ### Authentication
- **NextAuth.js** - For easy authentication setup - **NextAuth.js** - ✅ Implemented with role-based access and user management
- **Prisma** - For better database management (optional upgrade from better-sqlite3) - **Prisma** - For better database management (optional upgrade from better-sqlite3)
### Security ### Security
- **Zod** - Runtime type checking and validation - **Zod** - ✅ Implemented for validation
- **bcryptjs** - Password hashing - **bcryptjs** - ✅ Implemented for password hashing
- **rate-limiter-flexible** - Rate limiting - **rate-limiter-flexible** - Rate limiting (to implement)
### Reporting ### Reporting
- **Chart.js** or **Recharts** - Data visualization - **Recharts** - ✅ Installed for data visualization
- **jsPDF** - PDF generation - **jsPDF/jspdf-autotable** - ✅ Installed for PDF generation
- **xlsx** - Excel export - **exceljs/xlsx** - ✅ Installed for Excel export
### Notifications ### Notifications
- **Nodemailer** - Email sending - **Nodemailer** - Email sending (to implement)
- **Socket.io** - Real-time notifications - **Socket.io** - Real-time notifications (to implement)
### Testing ### Testing
@@ -302,13 +404,16 @@ This is a solid Next.js-based project management system for construction/enginee
5. **Docker support** for easy deployment 5. **Docker support** for easy deployment
6. **Map integration** with multiple layers 6. **Map integration** with multiple layers
7. **Modular components** that are reusable 7. **Modular components** that are reusable
8. **Authentication & Authorization** fully implemented with NextAuth.js
9. **Security foundations** (validation, hashing, audit logging)
10. **Reporting capabilities** with installed libraries for charts and exports
--- ---
## Estimated Development Time ## Estimated Development Time
- **Minimum Viable Professional App**: 8-12 weeks - **Minimum Viable Professional App**: 6-10 weeks
- **Full-featured Professional App**: 16-20 weeks - **Full-featured Professional App**: 14-18 weeks
- **Enterprise-grade Application**: 24-30 weeks - **Enterprise-grade Application**: 22-28 weeks
This assessment is based on a single developer working full-time. Team development could reduce these timelines significantly. This assessment is based on a single developer working full-time. Team development could reduce these timelines significantly.

18
add-assignable-column.mjs Normal file
View File

@@ -0,0 +1,18 @@
import db from "./src/lib/db.js";
console.log("Adding can_be_assigned column to users table...");
// Add the new column
db.prepare(`
ALTER TABLE users
ADD COLUMN can_be_assigned INTEGER DEFAULT 1
`).run();
// Set admin users to not be assignable by default
db.prepare(`
UPDATE users
SET can_be_assigned = 0
WHERE role = 'admin'
`).run();
console.log("Migration completed. Admin users are now not assignable by default.");

61
backup-db.mjs Normal file
View 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)}`);

22
check-contacts.mjs Normal file
View File

@@ -0,0 +1,22 @@
import db from './src/lib/db.js';
console.log('Checking contacts in database...\n');
const contacts = db.prepare('SELECT contact_id, name, phone, email, is_active, contact_type FROM contacts LIMIT 10').all();
console.log(`Total contacts found: ${contacts.length}\n`);
if (contacts.length > 0) {
console.log('Sample contacts:');
contacts.forEach(c => {
console.log(` ID: ${c.contact_id}, Name: ${c.name}, Phone: ${c.phone || 'N/A'}, Email: ${c.email || 'N/A'}, Active: ${c.is_active}, Type: ${c.contact_type}`);
});
} else {
console.log('No contacts found in database!');
}
const activeCount = db.prepare('SELECT COUNT(*) as count FROM contacts WHERE is_active = 1').get();
console.log(`\nActive contacts: ${activeCount.count}`);
const totalCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
console.log(`Total contacts: ${totalCount.count}`);

57
deploy.bat Normal file
View 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
View 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"

24
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,24 @@
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
- ./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

View File

@@ -2,12 +2,16 @@ 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
environment: environment:
- NODE_ENV=development - NODE_ENV=development
- TZ=Europe/Warsaw

32
docker-entrypoint-dev.sh Normal file
View File

@@ -0,0 +1,32 @@
#!/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
# Set proper permissions for uploads directory
chmod -R 755 /app/public/uploads
# 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
service cron start
# Start the development server
echo "✅ Starting development server..."
exec npm run dev

36
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/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
# Set proper permissions for uploads directory
chmod -R 755 /app/public/uploads
# 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
service cron start
# Start the application
echo "✅ Starting production server..."
exec npm start

View File

@@ -0,0 +1,58 @@
import * as XLSX from 'xlsx';
import { getAllProjects } from './src/lib/queries/projects.js';
function exportProjectsToExcel() {
try {
// Get all projects
const projects = getAllProjects();
// Group projects by status
const groupedProjects = projects.reduce((acc, project) => {
const status = project.project_status || 'unknown';
if (!acc[status]) {
acc[status] = [];
}
acc[status].push({
'Nazwa projektu': project.project_name,
'Adres': project.address || '',
'Działka': project.plot || '',
'WP': project.wp || '',
'Data zakończenia': project.finish_date || ''
});
return acc;
}, {});
// Polish status translations for sheet names
const statusTranslations = {
'registered': 'Zarejestrowany',
'in_progress_design': 'W realizacji (projektowanie)',
'in_progress_construction': 'W realizacji (budowa)',
'fulfilled': 'Zakończony',
'cancelled': 'Wycofany',
'unknown': 'Nieznany'
};
// Create workbook
const workbook = XLSX.utils.book_new();
// Create a sheet for each status
Object.keys(groupedProjects).forEach(status => {
const sheetName = statusTranslations[status] || status;
const worksheet = XLSX.utils.json_to_sheet(groupedProjects[status]);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
});
// Write to file
const filename = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
XLSX.writeFile(workbook, filename);
console.log(`Excel file created: ${filename}`);
console.log(`Sheets created for statuses: ${Object.keys(groupedProjects).join(', ')}`);
} catch (error) {
console.error('Error exporting projects to Excel:', error);
}
}
// Run the export
exportProjectsToExcel();

92
files-to-delete.md Normal file
View File

@@ -0,0 +1,92 @@
# Files to Delete from Codebase
Based on analysis of the workspace, the following files and folders appear to be temporary, debug-related, test-specific, or one-off scripts that should not remain in the production codebase. Review and delete as appropriate.
## Debug/Test Folders (entirely removable)
- `debug-disabled/` (and all subfolders: comprehensive-polish-map/, debug-polish-orthophoto/, test-improved-wmts/, test-polish-map/, test-polish-orthophoto/)
- `data/` (contains database.sqlite, likely a development database)
- `uploads/` (user-uploaded files that should be gitignored or stored elsewhere)
- `scripts/` (test data creation scripts: create-additional-test-data.js, create-admin.js, create-diverse-test-data.js, create-sample-projects.js, create-test-data.js)
## Test Files (one-off test scripts)
- test-audit-fix-direct.mjs
- test-audit-logging.mjs
- test-auth-api.mjs
- test-auth-detailed.mjs
- test-auth-pages.mjs
- test-auth-session.mjs
- test-auth.mjs
- test-complete-auth.mjs
- test-create-function.mjs
- test-current-audit-logs.mjs
- test-date-formatting.js
- test-dropdown-comprehensive.html
- test-dropdown.html
- test-edge-compatibility.mjs
- test-logged-in-flow.mjs
- test-logging.mjs
- test-mobile.html
- test-nextauth.mjs
- test-project-api.mjs
- test-project-creation.mjs
- test-safe-audit-logging.mjs
- test-task-api.mjs
- test-task-sets.mjs
- test-user-tracking.mjs
## Debug Files
- debug-dropdown.js
- debug-task-insert.mjs
## Check/Verification Scripts (one-off)
- check-audit-db.mjs
- check-columns.mjs
- check-projects-table.mjs
- check-projects.mjs
- check-schema.mjs
- check-task-schema.mjs
## Migration Scripts (likely already executed)
- migrate-add-completion-date.mjs
- migrate-add-edited-at-to-notes.mjs
- migrate-add-initial-column.mjs
- migrate-add-team-lead-role.mjs
- migrate-add-wartosc-zlecenia.mjs
- migrate-to-username.js
- run-migrations.sh
## Other One-Off Scripts
- add-assignable-column.mjs
- export-projects-to-excel.mjs
- fix-notes-columns.mjs
- fix-task-columns.mjs
- init-db-temp.mjs
- update-admin-username.js
- update-queries.ps1
- verify-audit-fix.mjs
- verify-project.mjs
## Implementation/Status Documentation (temporary notes)
- AUDIT_LOGGING_IMPLEMENTATION.md
- AUTHORIZATION_IMPLEMENTATION.md
- DEPLOYMENT_TIMEZONE_FIX.md
- DOCKER_GIT_DEPLOYMENT.md
- DOCKER_TIMEZONE_FIX.md
- DROPDOWN_COMPLETION_STATUS.md
- DROPDOWN_IMPLEMENTATION_SUMMARY.md
- EDGE_RUNTIME_FIX_FINAL.md
- EDGE_RUNTIME_FIX.md
- INTEGRATION_COMPLETE.md
- INTEGRATION_SUMMARY.md
- MERGE_COMPLETE.md
- MERGE_PREPARATION_SUMMARY.md
- POLISH_LAYERS_IMPLEMENTATION.md
## Development-Only Files
- start-dev.bat
## Potentially Keep (but review)
- deploy.bat / deploy.sh (if used for production deployment)
- geoportal-capabilities.xml (if it's configuration data)
This list focuses on files that seem to be development artifacts, temporary fixes, or test utilities. Before deletion, verify if any are still referenced in the codebase or needed for specific workflows. The core application code in `src/`, configuration files, and essential docs like `README.md` should remain.

5
init-db-temp.mjs Normal file
View File

@@ -0,0 +1,5 @@
import initializeDatabase from './src/lib/init-db.js';
console.log('Initializing database...');
initializeDatabase();
console.log('Database initialized successfully!');

View File

@@ -0,0 +1,29 @@
import db from "./src/lib/db.js";
export default function migrateAddCompletionDate() {
try {
// First, check if actual_completion_date exists and rename it to completion_date
const columns = db.prepare("PRAGMA table_info(projects)").all();
const hasActualCompletionDate = columns.some(col => col.name === 'actual_completion_date');
const hasCompletionDate = columns.some(col => col.name === 'completion_date');
if (hasActualCompletionDate && !hasCompletionDate) {
// Rename the column
db.exec(`
ALTER TABLE projects RENAME COLUMN actual_completion_date TO completion_date;
`);
console.log("Migration completed: Renamed actual_completion_date to completion_date");
} else if (!hasActualCompletionDate && !hasCompletionDate) {
// Add the column if it doesn't exist
db.exec(`
ALTER TABLE projects ADD COLUMN completion_date TEXT;
`);
console.log("Migration completed: Added completion_date column to projects table");
} else if (hasCompletionDate) {
console.log("Migration skipped: completion_date column already exists");
}
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}

View File

@@ -0,0 +1,27 @@
import db from "./src/lib/db.js";
export default function migrateAddEditedAtToNotes() {
try {
// Check if edited_at column already exists
const columns = db.prepare("PRAGMA table_info(notes)").all();
const hasEditedAt = columns.some(col => col.name === 'edited_at');
if (!hasEditedAt) {
// Add the edited_at column
db.exec(`
ALTER TABLE notes ADD COLUMN edited_at TEXT;
`);
console.log("Migration completed: Added edited_at column to notes table");
} else {
console.log("Migration skipped: edited_at column already exists");
}
} catch (error) {
console.error("Migration failed:", error);
throw error;
}
}
// Run the migration if this file is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
migrateAddEditedAtToNotes();
}

View File

@@ -0,0 +1,43 @@
import Database from "better-sqlite3";
// Migration script to add 'initial' column to users table
// Run this on your live server to apply the database changes
const dbPath = process.argv[2] || "./data/database.sqlite"; // Allow custom path via command line
console.log(`Applying migration to database: ${dbPath}`);
try {
const db = new Database(dbPath);
// Check if initial column already exists
const schema = db.prepare("PRAGMA table_info(users)").all();
const hasInitialColumn = schema.some(column => column.name === 'initial');
if (hasInitialColumn) {
console.log("✅ Initial column already exists in users table");
} else {
// Add the initial column
db.prepare("ALTER TABLE users ADD COLUMN initial TEXT").run();
console.log("✅ Added 'initial' column to users table");
}
// Verify the column was added
const updatedSchema = db.prepare("PRAGMA table_info(users)").all();
const initialColumn = updatedSchema.find(column => column.name === 'initial');
if (initialColumn) {
console.log("✅ Migration completed successfully");
console.log(`Column details: ${JSON.stringify(initialColumn, null, 2)}`);
} else {
console.error("❌ Migration failed - initial column not found");
process.exit(1);
}
db.close();
console.log("Database connection closed");
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
}

View File

@@ -0,0 +1,25 @@
import db from "./src/lib/db.js";
console.log("Adding settings table...");
try {
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
description TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_by TEXT,
FOREIGN KEY (updated_by) REFERENCES users(id)
);
`);
db.exec(`
INSERT OR IGNORE INTO settings (key, value, description) VALUES
('backup_notification_user_id', '', 'User ID to receive backup completion notifications');
`);
console.log("✅ Settings table created successfully");
} catch (error) {
console.error("Error creating settings table:", error);
}

View File

@@ -0,0 +1,75 @@
import db from './src/lib/db.js';
console.log('Starting migration to add team_lead role to users table constraint...');
try {
// Disable foreign key constraints temporarily
db.pragma('foreign_keys = OFF');
console.log('Disabled foreign key constraints');
// Since SQLite doesn't support modifying CHECK constraints directly,
// we need to recreate the table with the new constraint
// First, create a backup table with current data
db.exec('CREATE TABLE users_backup AS SELECT * FROM users');
console.log('Created backup table');
// Drop the original table
db.exec('DROP TABLE users');
console.log('Dropped original table');
// Recreate the table with the updated constraint
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
name TEXT NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT CHECK(role IN ('admin', 'team_lead', '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,
can_be_assigned INTEGER DEFAULT 1,
initial TEXT
)
`);
console.log('Created new table with updated constraint');
// Copy data back from backup
db.exec(`
INSERT INTO users (
id, name, username, password_hash, role, created_at, updated_at,
is_active, last_login, failed_login_attempts, locked_until,
can_be_assigned, initial
)
SELECT
id, name, username, password_hash, role, created_at, updated_at,
is_active, last_login, failed_login_attempts, locked_until,
can_be_assigned, initial
FROM users_backup
`);
console.log('Copied data back from backup');
// Drop the backup table
db.exec('DROP TABLE users_backup');
console.log('Dropped backup table');
// Re-enable foreign key constraints
db.pragma('foreign_keys = ON');
console.log('Re-enabled foreign key constraints');
// Verify the migration
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get();
console.log(`✅ Migration completed successfully! Users table now has ${userCount.count} records`);
// Verify the constraint allows the new role
console.log('✅ CHECK constraint now includes: admin, team_lead, project_manager, user, read_only');
} catch (error) {
console.error('❌ Migration failed:', error.message);
console.error('You may need to restore from backup manually');
process.exit(1);
}

View File

@@ -0,0 +1,36 @@
import db from './src/lib/db.js';
console.log('Starting migration to add wartosc_zlecenia field to projects table...');
try {
// Check if wartosc_zlecenia column already exists
const schema = db.prepare("PRAGMA table_info(projects)").all();
const hasWartoscZleceniaColumn = schema.some(column => column.name === 'wartosc_zlecenia');
if (hasWartoscZleceniaColumn) {
console.log("✅ wartosc_zlecenia column already exists in projects table");
} else {
// Add the wartosc_zlecenia column
db.prepare("ALTER TABLE projects ADD COLUMN wartosc_zlecenia REAL").run();
console.log("✅ Added 'wartosc_zlecenia' column to projects table");
}
// Verify the column was added
const updatedSchema = db.prepare("PRAGMA table_info(projects)").all();
const wartoscZleceniaColumn = updatedSchema.find(column => column.name === 'wartosc_zlecenia');
if (wartoscZleceniaColumn) {
console.log("✅ Migration completed successfully");
console.log(`Column details: ${JSON.stringify(wartoscZleceniaColumn, null, 2)}`);
} else {
console.error("❌ Migration failed - wartosc_zlecenia column not found");
process.exit(1);
}
db.close();
console.log("Database connection closed");
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
}

46
migrate-contacts.mjs Normal file
View File

@@ -0,0 +1,46 @@
import db from './src/lib/db.js';
import initializeDatabase from './src/lib/init-db.js';
console.log('🚀 Initializing contacts tables...\n');
try {
// Run database initialization which will create the new contacts tables
initializeDatabase();
console.log('✅ Contacts tables created successfully!\n');
// Check if there are projects with contact data in the old text field
const projectsWithContacts = db.prepare(`
SELECT project_id, project_name, contact
FROM projects
WHERE contact IS NOT NULL AND contact != ''
`).all();
if (projectsWithContacts.length > 0) {
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information in the old text field.\n`);
console.log('Sample contacts that could be migrated:');
projectsWithContacts.slice(0, 5).forEach(p => {
console.log(` - ${p.project_name}: "${p.contact}"`);
});
console.log('\n You can manually create contacts from the /contacts page and link them to projects.');
console.log(' The old contact field will remain in the database for reference.\n');
} else {
console.log(' No existing contact data found in projects.\n');
}
// Show table statistics
const contactsCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
const projectContactsCount = db.prepare('SELECT COUNT(*) as count FROM project_contacts').get();
console.log('📊 Database Statistics:');
console.log(` - Contacts: ${contactsCount.count}`);
console.log(` - Project-Contact Links: ${projectContactsCount.count}`);
console.log('\n✨ Migration complete! You can now:');
console.log(' 1. Visit /contacts to manage your contacts');
console.log(' 2. Add/edit projects to link contacts');
console.log(' 3. View linked contacts in project details\n');
} catch (error) {
console.error('❌ Error during migration:', error);
process.exit(1);
}

View File

@@ -0,0 +1,188 @@
import db from './src/lib/db.js';
import initializeDatabase from './src/lib/init-db.js';
console.log('🚀 Migrating contact data from projects...\n');
try {
// Run database initialization to ensure tables exist
initializeDatabase();
console.log('✅ Database tables verified\n');
// Get all projects with contact data
const projectsWithContacts = db.prepare(`
SELECT project_id, project_name, contact
FROM projects
WHERE contact IS NOT NULL AND contact != '' AND TRIM(contact) != ''
`).all();
if (projectsWithContacts.length === 0) {
console.log(' No contact data found in projects to migrate.\n');
process.exit(0);
}
console.log(`📋 Found ${projectsWithContacts.length} projects with contact information\n`);
let created = 0;
let linked = 0;
let skipped = 0;
const createContact = db.prepare(`
INSERT INTO contacts (name, phone, email, contact_type, notes, is_active)
VALUES (?, ?, ?, 'project', ?, 1)
`);
const linkContact = db.prepare(`
INSERT OR IGNORE INTO project_contacts (project_id, contact_id, is_primary, relationship_type)
VALUES (?, ?, 1, 'general')
`);
// Process each project
for (const project of projectsWithContacts) {
try {
const contactText = project.contact.trim();
// Parse contact information - common formats:
// "Jan Kowalski, tel. 123-456-789"
// "Jan Kowalski 123-456-789"
// "123-456-789"
// "Jan Kowalski"
let name = '';
let phone = '';
let email = '';
let notes = '';
// Try to extract email
const emailPattern = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/;
const emailMatch = contactText.match(emailPattern);
if (emailMatch) {
email = emailMatch[1].trim();
}
// Try to extract phone number (various formats)
const phonePatterns = [
/(?:\+?48)?[\s-]?(\d{3}[\s-]?\d{3}[\s-]?\d{3})/, // Polish: 123-456-789, 123 456 789, +48 123456789
/(?:\+?48)?[\s-]?(\d{9})/, // 9 digits
/tel\.?\s*[:.]?\s*([+\d\s-]+)/i, // tel. 123-456-789
/phone\s*[:.]?\s*([+\d\s-]+)/i, // phone: 123-456-789
/(\d{3}[-\s]?\d{3}[-\s]?\d{3})/, // Generic phone pattern
];
for (const pattern of phonePatterns) {
const match = contactText.match(pattern);
if (match) {
phone = match[1] || match[0];
phone = phone.replace(/\s+/g, ' ').trim();
break;
}
}
// Extract name (text before phone/email or comma)
let textForName = contactText;
if (phone) {
// Remove phone from text to get name
textForName = textForName.replace(phone, '');
}
if (email) {
// Remove email from text to get name
textForName = textForName.replace(email, '');
}
// Remove common prefixes like "tel.", "phone:", "email:", commas, etc.
name = textForName.replace(/tel\.?|phone:?|email:?|e-mail:?|,/gi, '').trim();
// Clean up name
name = name.replace(/^[,\s-]+|[,\s-]+$/g, '').trim();
// If we couldn't extract structured data, use project name and put original text in notes
if (!phone && !email) {
// No structured contact info found, put everything in notes
notes = `${contactText}`;
name = project.project_name;
} else if (!name) {
// We have phone/email but no clear name
name = project.project_name;
}
// Check if this contact already exists (by name, phone, or email)
let existingContact = null;
if (phone) {
existingContact = db.prepare(`
SELECT contact_id FROM contacts
WHERE phone LIKE ? OR phone LIKE ?
`).get(`%${phone}%`, `%${phone.replace(/\s/g, '')}%`);
}
if (!existingContact && email) {
existingContact = db.prepare(`
SELECT contact_id FROM contacts
WHERE LOWER(email) = LOWER(?)
`).get(email);
}
if (!existingContact && name && name !== project.project_name) {
existingContact = db.prepare(`
SELECT contact_id FROM contacts
WHERE LOWER(name) = LOWER(?)
`).get(name);
}
let contactId;
if (existingContact) {
contactId = existingContact.contact_id;
console.log(` ♻️ Using existing contact "${name}" for project "${project.project_name}"`);
} else {
// Create new contact
const result = createContact.run(
name,
phone || null,
email || null,
notes || `Przeniesiono z projektu: ${project.project_name}`
);
contactId = result.lastInsertRowid;
created++;
const contactInfo = [];
if (phone) contactInfo.push(`📞 ${phone}`);
if (email) contactInfo.push(`📧 ${email}`);
const infoStr = contactInfo.length > 0 ? ` (${contactInfo.join(', ')})` : '';
console.log(` ✨ Created contact "${name}"${infoStr} for project "${project.project_name}"`);
}
// Link contact to project
linkContact.run(project.project_id, contactId);
linked++;
} catch (error) {
console.error(` ❌ Error processing project "${project.project_name}":`, error.message);
skipped++;
}
}
console.log('\n📊 Migration Summary:');
console.log(` - Contacts created: ${created}`);
console.log(` - Project-contact links created: ${linked}`);
console.log(` - Projects skipped: ${skipped}`);
console.log(` - Total projects processed: ${projectsWithContacts.length}`);
// Show final statistics
const contactsCount = db.prepare('SELECT COUNT(*) as count FROM contacts').get();
const projectContactsCount = db.prepare('SELECT COUNT(*) as count FROM project_contacts').get();
console.log('\n📈 Current Database Statistics:');
console.log(` - Total contacts: ${contactsCount.count}`);
console.log(` - Total project-contact links: ${projectContactsCount.count}`);
console.log('\n✨ Migration complete!');
console.log(' - Visit /contacts to view and manage your contacts');
console.log(' - Edit projects to see linked contacts');
console.log(' - The old contact text field is preserved for reference\n');
} catch (error) {
console.error('❌ Error during migration:', error);
process.exit(1);
}

60
migrate-to-username.js Normal file
View File

@@ -0,0 +1,60 @@
import Database from "better-sqlite3";
const db = new Database("./data/database.sqlite");
console.log("🔄 Migrating database to username-based authentication...\n");
try {
// Check current table structure
const tableInfo = db.prepare("PRAGMA table_info(users)").all();
console.log("Current users table columns:");
tableInfo.forEach(col => console.log(` - ${col.name}: ${col.type}`));
const hasUsername = tableInfo.some(col => col.name === 'username');
const hasEmail = tableInfo.some(col => col.name === 'email');
if (hasUsername) {
console.log("✅ Username column already exists!");
} else if (hasEmail) {
console.log("\n📝 Adding username column...");
// Add username column
db.exec(`ALTER TABLE users ADD COLUMN username TEXT;`);
console.log("✅ Username column added");
// Copy email data to username for existing users
console.log("📋 Migrating existing email data to username...");
const result = db.exec(`UPDATE users SET username = email WHERE username IS NULL;`);
console.log("✅ Data migrated");
// Create unique index on username
console.log("🔍 Creating unique index on username...");
try {
db.exec(`CREATE UNIQUE INDEX idx_users_username_unique ON users(username);`);
console.log("✅ Unique index created");
} catch (e) {
console.log(" Index already exists or couldn't be created:", e.message);
}
// Verify migration
console.log("\n🔍 Verifying migration...");
const users = db.prepare("SELECT id, name, username, email FROM users LIMIT 3").all();
console.log("Sample users after migration:");
users.forEach(user => {
console.log(` - ${user.name}: username="${user.username}", email="${user.email || 'NULL'}"`);
});
console.log("\n✅ Migration completed successfully!");
console.log(" You can now log in using usernames instead of emails");
} else {
console.log("❌ Neither username nor email column found. Database may be corrupted.");
process.exit(1);
}
} catch (error) {
console.error("❌ Migration failed:", error.message);
process.exit(1);
} finally {
db.close();
}

2781
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,8 @@
"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",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
@@ -15,9 +17,14 @@
"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",
"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.8",
"next-auth": "^5.0.0-beta.29", "next-auth": "^5.0.0-beta.29",
@@ -28,6 +35,7 @@
"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": {
@@ -38,6 +46,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",

308
route_planning_readme.md Normal file
View File

@@ -0,0 +1,308 @@
# Route Planning Feature with Optimization
This feature allows you to plan routes between multiple project locations using OpenRouteService, with automatic optimization to find the fastest route regardless of point addition order.
## 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
### 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
- 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

31
run-migrations.sh Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Database migration runner for deployment
# This script runs all pending migrations in order
echo "🔄 Running database migrations..."
# List of migration scripts to run (in order)
MIGRATIONS=(
"migrate-add-team-lead-role.mjs"
"migrate-add-wartosc-zlecenia.mjs"
)
for migration in "${MIGRATIONS[@]}"; do
if [ -f "$migration" ]; then
echo "Running migration: $migration"
if node "$migration"; then
echo "✅ Migration $migration completed successfully"
# Optionally move completed migration to a completed folder
# mkdir -p migrations/completed
# mv "$migration" "migrations/completed/"
else
echo "❌ Migration $migration failed"
exit 1
fi
else
echo "Migration file $migration not found, skipping..."
fi
done
echo "✅ All migrations completed"

View File

@@ -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)

View File

@@ -0,0 +1,206 @@
import db from '../src/lib/db.js';
import initializeDatabase from '../src/lib/init-db.js';
// Initialize the database
initializeDatabase();
// Sample projects data
const sampleProjects = [
{
contract_id: 1,
project_name: 'Residential Complex Alpha',
address: 'ul. Główna 123',
plot: 'Plot 45/6',
district: 'Śródmieście',
unit: 'Unit A',
city: 'Warszawa',
investment_number: 'INV-2025-001',
finish_date: '2025-06-30',
wp: 'WP-001',
contact: 'Jan Kowalski, tel. 123-456-789',
notes: 'Modern residential building with 50 apartments',
coordinates: '52.2297,21.0122',
project_type: 'design+construction',
project_status: 'registered'
},
{
contract_id: 1,
project_name: 'Office Building Beta',
address: 'al. Jerozolimskie 50',
plot: 'Plot 12/8',
district: 'Mokotów',
unit: 'Unit B',
city: 'Warszawa',
investment_number: 'INV-2025-002',
finish_date: '2025-09-15',
wp: 'WP-002',
contact: 'Anna Nowak, tel. 987-654-321',
notes: 'Commercial office space, 10 floors',
coordinates: '52.2215,21.0071',
project_type: 'construction',
project_status: 'in_progress_design'
},
{
contract_id: 2,
project_name: 'Shopping Mall Gamma',
address: 'pl. Centralny 1',
plot: 'Plot 78/3',
district: 'Centrum',
unit: 'Unit C',
city: 'Kraków',
investment_number: 'INV-2025-003',
finish_date: '2025-12-20',
wp: 'WP-003',
contact: 'Piotr Wiśniewski, tel. 555-123-456',
notes: 'Large shopping center with parking',
coordinates: '50.0647,19.9450',
project_type: 'design+construction',
project_status: 'in_progress_construction'
},
{
contract_id: 2,
project_name: 'Industrial Warehouse Delta',
address: 'ul. Przemysłowa 100',
plot: 'Plot 200/15',
district: 'Przemysłowa',
unit: 'Unit D',
city: 'Łódź',
investment_number: 'INV-2025-004',
finish_date: '2025-08-10',
wp: 'WP-004',
contact: 'Maria Lewandowska, tel. 444-789-012',
notes: 'Logistics warehouse facility',
coordinates: '51.7592,19.4600',
project_type: 'design',
project_status: 'fulfilled'
},
{
contract_id: 1,
project_name: 'Hotel Complex Epsilon',
address: 'ul. Morska 25',
plot: 'Plot 5/2',
district: 'Nadmorze',
unit: 'Unit E',
city: 'Gdańsk',
investment_number: 'INV-2025-005',
finish_date: '2025-11-05',
wp: 'WP-005',
contact: 'Tomasz Malinowski, tel. 333-456-789',
notes: 'Luxury hotel with conference facilities',
coordinates: '54.3520,18.6466',
project_type: 'design+construction',
project_status: 'registered'
},
{
contract_id: 2,
project_name: 'School Complex Zeta',
address: 'ul. Edukacyjna 15',
plot: 'Plot 30/4',
district: 'Edukacyjny',
unit: 'Unit F',
city: 'Poznań',
investment_number: 'INV-2025-006',
finish_date: '2025-07-20',
wp: 'WP-006',
contact: 'Ewa Dombrowska, tel. 222-333-444',
notes: 'Modern educational facility with sports complex',
coordinates: '52.4064,16.9252',
project_type: 'design',
project_status: 'in_progress_design'
},
{
contract_id: 1,
project_name: 'Medical Center Eta',
address: 'al. Zdrowia 8',
plot: 'Plot 67/9',
district: 'Medyczny',
unit: 'Unit G',
city: 'Wrocław',
investment_number: 'INV-2025-007',
finish_date: '2025-10-30',
wp: 'WP-007',
contact: 'Dr. Marek Szymankowski, tel. 111-222-333',
notes: 'Specialized medical center with emergency department',
coordinates: '51.1079,17.0385',
project_type: 'construction',
project_status: 'in_progress_construction'
},
{
contract_id: 2,
project_name: 'Sports Stadium Theta',
address: 'ul. Sportowa 50',
plot: 'Plot 150/20',
district: 'Sportowy',
unit: 'Unit H',
city: 'Szczecin',
investment_number: 'INV-2025-008',
finish_date: '2025-05-15',
wp: 'WP-008',
contact: 'Katarzyna Wojcik, tel. 999-888-777',
notes: 'Multi-purpose sports stadium with seating for 20,000',
coordinates: '53.4289,14.5530',
project_type: 'design+construction',
project_status: 'fulfilled'
},
{
contract_id: 1,
project_name: 'Library Complex Iota',
address: 'pl. Wiedzy 3',
plot: 'Plot 25/7',
district: 'Kulturalny',
unit: 'Unit I',
city: 'Lublin',
investment_number: 'INV-2025-009',
finish_date: '2025-08-25',
wp: 'WP-009',
contact: 'Prof. Andrzej Kowalewski, tel. 777-666-555',
notes: 'Modern library with digital archives and community spaces',
coordinates: '51.2465,22.5684',
project_type: 'design',
project_status: 'registered'
}
];
console.log('Creating sample test projects...\n');
sampleProjects.forEach((projectData, index) => {
try {
// Generate project number based on contract
const contractInfo = db.prepare('SELECT contract_number FROM contracts WHERE contract_id = ?').get(projectData.contract_id);
const existingProjects = db.prepare('SELECT COUNT(*) as count FROM projects WHERE contract_id = ?').get(projectData.contract_id);
const sequentialNumber = existingProjects.count + 1;
const projectNumber = `${sequentialNumber}/${contractInfo.contract_number}`;
const result = db.prepare(`
INSERT INTO projects (
contract_id, project_name, project_number, address, plot, district, unit, city,
investment_number, finish_date, wp, contact, notes, coordinates,
project_type, project_status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).run(
projectData.contract_id,
projectData.project_name,
projectNumber,
projectData.address,
projectData.plot,
projectData.district,
projectData.unit,
projectData.city,
projectData.investment_number,
projectData.finish_date,
projectData.wp,
projectData.contact,
projectData.notes,
projectData.coordinates,
projectData.project_type,
projectData.project_status
);
console.log(`✓ Created project: ${projectData.project_name} (ID: ${result.lastInsertRowid}, Number: ${projectNumber})`);
} catch (error) {
console.error(`✗ Error creating project ${projectData.project_name}:`, error.message);
}
});
console.log('\nSample test projects created successfully!');

View File

@@ -0,0 +1,183 @@
"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([]);
// Redirect if not admin
useEffect(() => {
if (status === "loading") return;
if (!session || session.user.role !== "admin") {
router.push("/");
return;
}
fetchSettings();
fetchUsers();
}, [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 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">
<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>
);
}

View File

@@ -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"

View File

@@ -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"}

View 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);

View File

@@ -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) {

View File

@@ -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 }
); );
} }

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
});

View File

@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
import {
getContactById,
updateContact,
deleteContact,
hardDeleteContact,
} from "@/lib/queries/contacts";
import { withAuth } from "@/lib/middleware/auth";
// 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 }
);
}
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);
} else {
// Soft delete - set is_active to 0
deleteContact(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);

View File

@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import {
getAllContacts,
createContact,
getContactStats,
} from "@/lib/queries/contacts";
import { withAuth } from "@/lib/middleware/auth";
// 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);
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);

View File

@@ -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

View File

@@ -0,0 +1,262 @@
// Force this API route to use Node.js runtime
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { getAllProjects } from "@/lib/queries/projects";
export async function GET(request) {
try {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Only team leads can access dashboard data
if (session.user.role !== 'team_lead') {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const selectedYear = searchParams.get('year') ? parseInt(searchParams.get('year')) : null;
// Get all projects
const projects = getAllProjects();
// Calculate realised and unrealised values by project type
const projectTypes = ['design', 'design+construction', 'construction'];
const typeSummary = {};
projectTypes.forEach(type => {
typeSummary[type] = {
realisedValue: 0,
unrealisedValue: 0
};
});
projects.forEach(project => {
const value = parseFloat(project.wartosc_zlecenia) || 0;
const type = project.project_type;
if (!type || !projectTypes.includes(type)) return;
if (project.project_status === 'fulfilled' && project.completion_date && project.wartosc_zlecenia) {
typeSummary[type].realisedValue += value;
} else if (project.wartosc_zlecenia && project.project_status !== 'cancelled') {
typeSummary[type].unrealisedValue += value;
}
});
// Calculate overall totals
let realisedValue = 0;
let unrealisedValue = 0;
Object.values(typeSummary).forEach(summary => {
realisedValue += summary.realisedValue;
unrealisedValue += summary.unrealisedValue;
});
// Filter completed projects (those with completion_date and fulfilled status)
const completedProjects = projects.filter(project =>
project.completion_date &&
project.wartosc_zlecenia &&
project.project_status === 'fulfilled'
);
// If no data, return sample data for demonstration
let chartData;
let summary;
if (completedProjects.length === 0) {
// Generate continuous sample data based on selected year or default range
const currentDate = new Date();
let startDate, endDate;
if (selectedYear) {
startDate = new Date(selectedYear, 0, 1); // Jan 1st of selected year
endDate = new Date(selectedYear, 11, 31); // Dec 31st of selected year
if (endDate > currentDate) endDate = currentDate;
} else {
startDate = new Date(2024, 0, 1); // Jan 2024
endDate = currentDate;
}
chartData = [];
let cumulative = 0;
let tempDate = new Date(startDate);
while (tempDate <= endDate) {
const monthName = tempDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
let monthlyValue = 0;
// Add some sample values for certain months (only if they match the selected year or no year selected)
const shouldAddData = !selectedYear || tempDate.getFullYear() === selectedYear;
if (shouldAddData) {
if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2024) monthlyValue = 50000; // Jan 2024
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2024) monthlyValue = 75000; // Feb 2024
else if (tempDate.getMonth() === 2 && tempDate.getFullYear() === 2024) monthlyValue = 60000; // Mar 2024
else if (tempDate.getMonth() === 7 && tempDate.getFullYear() === 2024) monthlyValue = 10841; // Aug 2024 (real data)
else if (tempDate.getMonth() === 8 && tempDate.getFullYear() === 2024) monthlyValue = 18942; // Sep 2024
else if (tempDate.getMonth() === 9 && tempDate.getFullYear() === 2024) monthlyValue = 13945; // Oct 2024
else if (tempDate.getMonth() === 10 && tempDate.getFullYear() === 2024) monthlyValue = 12542; // Nov 2024
else if (tempDate.getMonth() === 0 && tempDate.getFullYear() === 2025) monthlyValue = 25000; // Jan 2025
else if (tempDate.getMonth() === 1 && tempDate.getFullYear() === 2025) monthlyValue = 35000; // Feb 2025
}
cumulative += monthlyValue;
chartData.push({
month: monthName,
value: monthlyValue,
cumulative: cumulative
});
tempDate.setMonth(tempDate.getMonth() + 1);
}
summary = {
total: {
realisedValue: 958000,
unrealisedValue: 1242000
},
byType: {
design: {
realisedValue: 320000,
unrealisedValue: 480000
},
'design+construction': {
realisedValue: 480000,
unrealisedValue: 520000
},
construction: {
realisedValue: 158000,
unrealisedValue: 242000
}
}
};
} else {
// Group by month and calculate monthly totals first
const monthlyData = {};
// Sort projects by completion date
completedProjects.sort((a, b) => new Date(a.completion_date) - new Date(b.completion_date));
// First pass: calculate monthly totals
completedProjects.forEach(project => {
const date = new Date(project.completion_date);
const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthName = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
if (!monthlyData[monthKey]) {
monthlyData[monthKey] = {
month: monthName,
value: 0
};
}
const projectValue = parseFloat(project.wartosc_zlecenia) || 0;
monthlyData[monthKey].value += projectValue;
});
// Generate continuous timeline from earliest completion to current date
let startDate = new Date();
let endDate = new Date();
if (completedProjects.length > 0) {
// Find earliest completion date
const earliestCompletion = completedProjects.reduce((earliest, project) => {
const projectDate = new Date(project.completion_date);
return projectDate < earliest ? projectDate : earliest;
}, new Date());
startDate = new Date(earliestCompletion.getFullYear(), earliestCompletion.getMonth(), 1);
} else {
// If no completed projects, start from 6 months ago
startDate = new Date();
startDate.setMonth(startDate.getMonth() - 6);
startDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1);
}
// If a specific year is selected, adjust the date range
if (selectedYear) {
startDate = new Date(selectedYear, 0, 1); // January 1st of selected year
endDate = new Date(selectedYear, 11, 31); // December 31st of selected year
// Don't go beyond current date
if (endDate > new Date()) {
endDate = new Date();
}
}
// Generate all months from start to current
const allMonths = {};
let currentDate = new Date(startDate);
while (currentDate <= endDate) {
const monthKey = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
const monthName = currentDate.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
allMonths[monthKey] = {
month: monthName,
value: monthlyData[monthKey]?.value || 0
};
currentDate.setMonth(currentDate.getMonth() + 1);
}
// Calculate cumulative values
let cumulativeValue = 0;
const sortedMonths = Object.keys(allMonths).sort((a, b) => a.localeCompare(b));
sortedMonths.forEach(monthKey => {
cumulativeValue += allMonths[monthKey].value;
allMonths[monthKey].cumulative = cumulativeValue;
});
// Convert to array
chartData = sortedMonths.map(monthKey => ({
month: allMonths[monthKey].month,
value: Math.round(allMonths[monthKey].value),
cumulative: Math.round(allMonths[monthKey].cumulative)
}));
summary = {
total: {
realisedValue: Math.round(realisedValue),
unrealisedValue: Math.round(unrealisedValue)
},
byType: Object.fromEntries(
Object.entries(typeSummary).map(([type, data]) => [
type,
{
realisedValue: Math.round(data.realisedValue),
unrealisedValue: Math.round(data.unrealisedValue)
}
])
)
};
}
return NextResponse.json({
chartData,
summary: {
total: {
realisedValue: Math.round(realisedValue),
unrealisedValue: Math.round(unrealisedValue)
},
byType: Object.fromEntries(
Object.entries(typeSummary).map(([type, data]) => [
type,
{
realisedValue: Math.round(data.realisedValue),
unrealisedValue: Math.round(data.unrealisedValue)
}
])
)
}
});
} catch (error) {
console.error('Dashboard API error:', error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}

View 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);

View 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
View 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 }
);
}
}

View 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);

View File

@@ -3,13 +3,59 @@ 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 { import {
logApiActionSafe, logApiActionSafe,
AUDIT_ACTIONS, AUDIT_ACTIONS,
RESOURCE_TYPES, RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js"; } 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();
@@ -22,11 +68,25 @@ async function createNoteHandler(req) {
.prepare( .prepare(
` `
INSERT INTO notes (project_id, task_id, note, created_by, note_date) INSERT INTO notes (project_id, task_id, note, created_by, note_date)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, ?, ?, datetime('now', 'localtime'))
` `
) )
.run(project_id || null, task_id || null, note, req.user?.id || null); .run(project_id || null, task_id || null, note, req.user?.id || null);
// Get the created note with user info
const createdNote = db
.prepare(
`
SELECT n.*,
u.name as created_by_name,
u.username as created_by_username
FROM notes n
LEFT JOIN users u ON n.created_by = u.id
WHERE n.note_id = ?
`
)
.get(result.lastInsertRowid);
// Log note creation // Log note creation
await logApiActionSafe( await logApiActionSafe(
req, req,
@@ -39,7 +99,7 @@ async function createNoteHandler(req) {
} }
); );
return NextResponse.json({ success: true }); return NextResponse.json(createdNote);
} catch (error) { } catch (error) {
console.error("Error creating note:", error); console.error("Error creating note:", error);
return NextResponse.json( return NextResponse.json(
@@ -50,7 +110,7 @@ async function createNoteHandler(req) {
} }
async function deleteNoteHandler(req, { params }) { async function deleteNoteHandler(req, { params }) {
const { id } = params; const { id } = await params;
// Get note data before deletion for audit log // Get note data before deletion for audit log
const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id); const note = db.prepare("SELECT * FROM notes WHERE note_id = ?").get(id);
@@ -76,48 +136,7 @@ async function deleteNoteHandler(req, { params }) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
async function updateNoteHandler(req, { params }) {
const noteId = params.id;
const { note } = await req.json();
if (!note || !noteId) {
return NextResponse.json({ error: "Missing note or ID" }, { status: 400 });
}
// Get original note for audit log
const originalNote = db
.prepare("SELECT * FROM notes WHERE note_id = ?")
.get(noteId);
db.prepare(
`
UPDATE notes SET note = ? WHERE note_id = ?
`
).run(note, noteId);
// Log note update
await logApiActionSafe(
req,
AUDIT_ACTIONS.NOTE_UPDATE,
RESOURCE_TYPES.NOTE,
noteId,
req.auth, // Use req.auth instead of req.session
{
originalNote: {
note_length: originalNote?.note?.length || 0,
project_id: originalNote?.project_id,
task_id: originalNote?.task_id,
},
updatedNote: {
note_length: note.length,
},
}
);
return NextResponse.json({ success: true });
}
// Protected routes - require authentication // 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);

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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,7 +49,15 @@ async function updateProjectTaskHandler(req, { params }) {
); );
} }
updateProjectTaskStatus(params.id, status, req.user?.id || null); const allowedStatuses = ['not_started', 'pending', 'in_progress', 'completed', 'cancelled'];
if (!allowedStatuses.includes(status)) {
return NextResponse.json(
{ error: "Invalid status. Must be one of: " + allowedStatuses.join(', ') },
{ status: 400 }
);
}
updateProjectTaskStatus(id, status, req.user?.id || null);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error updating task status:", error); console.error("Error updating task status:", error);
@@ -31,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);

View File

@@ -47,10 +47,19 @@ async function createProjectTaskHandler(req) {
const taskData = { const taskData = {
...data, ...data,
created_by: req.user?.id || null, created_by: req.user?.id || null,
// If no assigned_to is specified, default to the creator
assigned_to: data.assigned_to || req.user?.id || null,
}; };
// Set assigned_to: if specified, use it; otherwise default to creator only if they're not admin
if (data.assigned_to) {
taskData.assigned_to = data.assigned_to;
} else if (req.user?.id) {
// Check if the creator is an admin - if so, don't assign to them
const userRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user.id);
taskData.assigned_to = userRole?.role === 'admin' ? null : req.user.id;
} else {
taskData.assigned_to = null;
}
const result = createProjectTask(taskData); const result = createProjectTask(taskData);
return NextResponse.json({ success: true, id: result.lastInsertRowid }); return NextResponse.json({ success: true, id: result.lastInsertRowid });
} catch (error) { } catch (error) {

View 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);

View 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);

View File

@@ -3,9 +3,12 @@ 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 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";
@@ -14,13 +17,14 @@ import {
AUDIT_ACTIONS, AUDIT_ACTIONS,
RESOURCE_TYPES, RESOURCE_TYPES,
} from "@/lib/auditLogSafe.js"; } from "@/lib/auditLogSafe.js";
import { getUserLanguage, serverT } from "@/lib/serverTranslations";
// Make sure the DB is initialized before queries run // Make sure the DB is initialized before queries run
initializeDatabase(); initializeDatabase();
async function getProjectHandler(req, { params }) { async function getProjectHandler(req, { params }) {
const { id } = await params; const { id } = await params;
const project = getProjectById(parseInt(id)); const project = getProjectWithContract(parseInt(id));
if (!project) { if (!project) {
return NextResponse.json({ error: "Project not found" }, { status: 404 }); return NextResponse.json({ error: "Project not found" }, { status: 404 });
@@ -40,35 +44,86 @@ async function getProjectHandler(req, { params }) {
} }
async function updateProjectHandler(req, { params }) { async function updateProjectHandler(req, { params }) {
const { id } = await params; try {
const data = await req.json(); const { id } = await params;
const data = await req.json();
// Get user ID from authenticated request // Get user ID from authenticated request
const userId = req.user?.id; const userId = req.user?.id;
// Get original project data for audit log // Get original project data for audit log and field tracking
const originalProject = getProjectById(parseInt(id)); const originalProject = getProjectById(parseInt(id));
updateProject(parseInt(id), data, userId); if (!originalProject) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
// 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); // 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(req, { params }) { async function deleteProjectHandler(req, { params }) {

View 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);

View 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 }
);
}
}

View 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);

View 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);

View 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);

View 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);

View File

@@ -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(

View File

@@ -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 });
} }

View File

@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"
import { useSearchParams } from "next/navigation" import { useSearchParams } from "next/navigation"
function SignInContent() { 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 @@ function SignInContent() {
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 @@ function SignInContent() {
<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 @@ function SignInContent() {
<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 @@ function SignInContent() {
<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,13 +113,13 @@ function SignInContent() {
</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>

434
src/app/calendar/page.js Normal file
View 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>
);
}

615
src/app/contacts/page.js Normal file
View File

@@ -0,0 +1,615 @@
"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";
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 (
<div className="container mx-auto px-4 py-8">
<ContactForm
initialData={editingContact}
onSave={handleFormSave}
onCancel={handleFormCancel}
/>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">Kontakty</h1>
<p className="text-gray-600 mt-1">
Zarządzaj kontaktami do projektów i współpracy
</p>
</div>
<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>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-gray-900">
{stats.total_contacts}
</div>
<div className="text-sm text-gray-600">Wszystkie</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-blue-600">
{stats.project_contacts}
</div>
<div className="text-sm text-gray-600">Projekty</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-orange-600">
{stats.contractor_contacts}
</div>
<div className="text-sm text-gray-600">Wykonawcy</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-purple-600">
{stats.office_contacts}
</div>
<div className="text-sm text-gray-600">Urzędy</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-green-600">
{stats.supplier_contacts}
</div>
<div className="text-sm text-gray-600">Dostawcy</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="text-2xl font-bold text-gray-600">
{stats.other_contacts}
</div>
<div className="text-sm text-gray-600">Inne</div>
</CardContent>
</Card>
</div>
)}
{/* Filters */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Szukaj po nazwie, telefonie, email lub firmie..."
className="w-full px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Wszystkie typy</option>
<option value="project">Projekty</option>
<option value="contractor">Wykonawcy</option>
<option value="office">Urzędy</option>
<option value="supplier">Dostawcy</option>
<option value="other">Inne</option>
</select>
</div>
</CardContent>
</Card>
{/* Contacts List */}
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kontakt
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Firma / Stanowisko
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Telefon
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Akcje
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredContacts.length === 0 ? (
<tr>
<td colSpan="5" className="px-4 py-12 text-center text-gray-500">
{searchTerm || typeFilter !== "all"
? "Nie znaleziono kontaktów"
: "Brak kontaktów. Dodaj pierwszy kontakt."}
</td>
</tr>
) : (
filteredContacts.map((contact) => {
const typeBadge = getContactTypeBadge(contact.contact_type);
return (
<tr key={contact.contact_id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2 cursor-pointer" onClick={() => handleViewDetails(contact)}>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 text-sm hover:text-blue-600 transition-colors">
{contact.name}
</h3>
<Badge variant={typeBadge.variant} size="sm" className="text-xs">
{typeBadge.label}
</Badge>
</div>
{contact.project_count > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-gray-500 mt-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{contact.project_count} {contact.project_count === 1 ? "projekt" : "projektów"}
</span>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm text-gray-600">
{contact.company && (
<div className="flex items-center gap-1">
<span>{contact.company}</span>
{contact.position && <span className="text-gray-500 ml-1"> {contact.position}</span>}
</div>
)}
{!contact.company && contact.position && (
<div>{contact.position}</div>
)}
</div>
</td>
<td className="px-4 py-3">
{contact.phone && (
<div className="space-y-1">
{(() => {
// Handle multiple phones (could be comma-separated or JSON)
let phones = [];
try {
// Try to parse as JSON array first
const parsed = JSON.parse(contact.phone);
phones = Array.isArray(parsed) ? parsed : [contact.phone];
} catch {
// Fall back to comma-separated string
phones = contact.phone.split(',').map(p => p.trim()).filter(p => p);
}
const primaryPhone = phones[0];
const additionalPhones = phones.slice(1);
return (
<>
<a
href={`tel:${primaryPhone}`}
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
{primaryPhone}
</a>
{additionalPhones.length > 0 && (
<div className="text-xs text-gray-500 pl-5">
{additionalPhones.length === 1 ? (
<a
href={`tel:${additionalPhones[0]}`}
className="text-blue-600 hover:underline"
>
{additionalPhones[0]}
</a>
) : (
<span>+{additionalPhones.length} więcej</span>
)}
</div>
)}
</>
);
})()}
</div>
)}
</td>
<td className="px-4 py-3">
{contact.email && (
<a
href={`mailto:${contact.email}`}
className="flex items-center gap-1 text-sm text-blue-600 hover:underline"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span className="truncate max-w-[200px]">{contact.email}</span>
</a>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button
size="sm"
variant="secondary"
onClick={(e) => {
e.stopPropagation();
handleEdit(contact);
}}
className="px-2 py-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</Button>
<Button
size="sm"
variant="danger"
onClick={(e) => {
e.stopPropagation();
handleDelete(contact.contact_id);
}}
className="px-2 py-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Contact Details Modal */}
{selectedContact && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" onClick={closeDetails}>
<Card className="max-w-2xl w-full max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<CardHeader className="border-b">
<div className="flex justify-between items-start">
<div>
<CardTitle className="text-2xl">{selectedContact.name}</CardTitle>
<div className="mt-2">
<Badge variant={getContactTypeBadge(selectedContact.contact_type).variant}>
{getContactTypeBadge(selectedContact.contact_type).label}
</Badge>
</div>
</div>
<Button variant="ghost" size="sm" onClick={closeDetails}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* Contact Information */}
<div>
<h3 className="font-semibold text-gray-900 mb-3">Informacje kontaktowe</h3>
<div className="space-y-2">
{selectedContact.phone && (() => {
let phones = [];
try {
const parsed = JSON.parse(selectedContact.phone);
phones = Array.isArray(parsed) ? parsed : [selectedContact.phone];
} catch {
phones = selectedContact.phone.split(',').map(p => p.trim()).filter(p => p);
}
return phones.map((phone, index) => (
<div key={index} className="flex items-center gap-3">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<a href={`tel:${phone}`} className="text-blue-600 hover:underline">
{phone}
</a>
{index === 0 && phones.length > 1 && (
<span className="text-xs text-gray-500">(główny)</span>
)}
</div>
));
})()}
{selectedContact.email && (
<div className="flex items-center gap-3">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<a href={`mailto:${selectedContact.email}`} className="text-blue-600 hover:underline">
{selectedContact.email}
</a>
</div>
)}
{selectedContact.company && (
<div className="flex items-center gap-3">
<span className="text-xl">🏢</span>
<span className="text-gray-700">
{selectedContact.company}
{selectedContact.position && `${selectedContact.position}`}
</span>
</div>
)}
{!selectedContact.company && selectedContact.position && (
<div className="flex items-center gap-3">
<span className="text-xl">💼</span>
<span className="text-gray-700">{selectedContact.position}</span>
</div>
)}
</div>
</div>
{/* Notes */}
{selectedContact.notes && (
<div>
<h3 className="font-semibold text-gray-900 mb-2">Notatki</h3>
<p className="text-gray-600 text-sm whitespace-pre-wrap bg-gray-50 p-3 rounded">
{selectedContact.notes}
</p>
</div>
)}
{/* Linked Projects */}
<div>
<h3 className="font-semibold text-gray-900 mb-3">
Powiązane projekty ({contactProjects.length})
</h3>
{loadingProjects ? (
<div className="text-center py-4 text-gray-500">Ładowanie projektów...</div>
) : contactProjects.length === 0 ? (
<p className="text-gray-500 text-sm">Brak powiązanych projektów</p>
) : (
<div className="space-y-2">
{contactProjects.map((project) => (
<div
key={project.project_id}
className="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded cursor-pointer transition-colors"
onClick={() => router.push(`/projects/${project.project_id}`)}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{project.project_name}</span>
{project.is_primary && (
<Badge variant="primary" size="sm">Główny kontakt</Badge>
)}
</div>
{project.relationship_type && (
<span className="text-xs text-gray-500">{project.relationship_type}</span>
)}
</div>
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
))}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-3 pt-4 border-t">
<Button
variant="secondary"
onClick={() => {
closeDetails();
handleEdit(selectedContact);
}}
className="flex-1"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edytuj kontakt
</Button>
<Button variant="ghost" onClick={closeDetails}>
Zamknij
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -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,7 @@ 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> </Button>
</Link> </Link>
<Link href={`/projects/new?contract_id=${contractId}`}> <Link href={`/projects/new?contract_id=${contractId}`}>
@@ -114,7 +128,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 +141,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 +157,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 +167,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 +177,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 +187,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 +197,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 +207,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 +223,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 +249,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 +259,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 +319,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 +341,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 +413,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 +438,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>
); );
} }

View File

@@ -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>
} }

View File

@@ -11,14 +11,16 @@ 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 [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");
useEffect(() => { useEffect(() => {
@@ -51,6 +53,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 +67,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;
@@ -115,31 +120,31 @@ export default function ContractsMainPage() {
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>;
} }
}; };
@@ -170,17 +175,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 +212,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 +224,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,13 +242,25 @@ 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>
@@ -312,9 +341,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>

289
src/app/dashboard/page.js Normal file
View File

@@ -0,0 +1,289 @@
"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";
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 (
<div className="container mx-auto px-4 py-8">
<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: '#f59e0b'
}
]}
cx="50%"
cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
>
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</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-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
<div className="text-sm text-amber-600 dark:text-amber-400 font-medium">{t('teamDashboard.unrealisedValue')}</div>
<div className="text-xl font-bold text-amber-700 dark:text-amber-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">
{type.replace('+', ' + ')}
</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: '#f59e0b' }
]}
cx="50%"
cy="50%"
outerRadius={45}
dataKey="value"
label={({ percent }) => percent > 0.1 ? `${(percent * 100).toFixed(0)}%` : ''}
>
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</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-amber-600 dark:text-amber-400">{t('teamDashboard.unrealised')}</span>
<span className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{formatCurrency(data.unrealisedValue)}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -5,6 +5,91 @@
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
/* Surface colors */
--surface-primary: #ffffff;
--surface-secondary: #f9fafb;
--surface-tertiary: #f3f4f6;
--surface-modal: #ffffff;
--surface-card: #ffffff;
--surface-hover: #f9fafb;
--surface-active: #f3f4f6;
/* Text colors */
--text-primary: #111827;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--text-inverse: #ffffff;
--text-muted: #6b7280;
/* Border colors */
--border-default: #d1d5db;
--border-hover: #9ca3af;
--border-focus: #3b82f6;
--border-divider: #e5e7eb;
/* Interactive colors */
--interactive-primary: #3b82f6;
--interactive-primary-hover: #2563eb;
--interactive-secondary: #6b7280;
--interactive-secondary-hover: #4b5563;
--interactive-danger: #ef4444;
--interactive-danger-hover: #dc2626;
/* Status colors */
--status-success: #10b981;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-info: #3b82f6;
/* Shadow colors */
--shadow-default: rgba(0, 0, 0, 0.1);
--shadow-hover: rgba(0, 0, 0, 0.15);
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
/* Surface colors */
--surface-primary: #1f2937;
--surface-secondary: #374151;
--surface-tertiary: #4b5563;
--surface-modal: #1f2937;
--surface-card: #374151;
--surface-hover: #4b5563;
--surface-active: #6b7280;
/* Text colors */
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-tertiary: #9ca3af;
--text-inverse: #111827;
--text-muted: #9ca3af;
/* Border colors */
--border-default: #4b5563;
--border-hover: #6b7280;
--border-focus: #60a5fa;
--border-divider: #374151;
/* Interactive colors */
--interactive-primary: #3b82f6;
--interactive-primary-hover: #60a5fa;
--interactive-secondary: #6b7280;
--interactive-secondary-hover: #9ca3af;
--interactive-danger: #ef4444;
--interactive-danger-hover: #f87171;
/* Status colors */
--status-success: #10b981;
--status-warning: #f59e0b;
--status-error: #ef4444;
--status-info: #3b82f6;
/* Shadow colors */
--shadow-default: rgba(0, 0, 0, 0.3);
--shadow-hover: rgba(0, 0, 0, 0.4);
} }
/* @media (prefers-color-scheme: dark) { /* @media (prefers-color-scheme: dark) {
@@ -29,6 +114,18 @@ body {
background: #f1f1f1; background: #f1f1f1;
} }
::-webkit-scrollbar-track:window-inactive {
background: #f1f1f1;
}
.dark ::-webkit-scrollbar-track {
background: #374151;
}
.dark ::-webkit-scrollbar-track:window-inactive {
background: #374151;
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #c1c1c1; background: #c1c1c1;
border-radius: 4px; border-radius: 4px;
@@ -38,6 +135,14 @@ body {
background: #a8a8a8; background: #a8a8a8;
} }
.dark ::-webkit-scrollbar-thumb {
background: #6b7280;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* Focus styles */ /* Focus styles */
.focus-ring { .focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2; @apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
@@ -92,5 +197,66 @@ body {
/* Map controls positioning */ /* Map controls positioning */
.leaflet-control-container .leaflet-top.leaflet-right { .leaflet-control-container .leaflet-top.leaflet-right {
top: 80px !important; /* Account for floating header */ top: 10px !important; /* Position closer to top for project page */
right: 10px !important;
}
/* Style the layer control to make it prettier */
.leaflet-control-layers {
border-radius: 6px !important;
border: 1px solid rgba(0, 0, 0, 0.1) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1) !important;
background: rgba(255, 255, 255, 0.95) !important;
backdrop-filter: blur(8px) !important;
-webkit-backdrop-filter: blur(8px) !important;
font-family: inherit !important;
font-size: 13px !important;
min-width: 180px !important;
max-width: 220px !important;
}
.leaflet-control-layers-toggle {
background-color: #3b82f6 !important;
color: white !important;
border-radius: 4px !important;
width: 30px !important;
height: 30px !important;
border: none !important;
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.2) !important;
transition: all 0.2s ease !important;
font-size: 16px !important;
line-height: 1 !important;
}
.leaflet-control-layers-toggle:hover {
background-color: #2563eb !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3) !important;
transform: scale(1.05) !important;
}
.leaflet-control-layers-list {
border-radius: 4px !important;
padding: 6px !important;
}
.leaflet-control-layers-base label,
.leaflet-control-layers-overlays label {
padding: 4px 8px !important;
border-radius: 3px !important;
margin: 1px 0 !important;
transition: background-color 0.2s ease !important;
cursor: pointer !important;
font-size: 12px !important;
line-height: 1.3 !important;
}
.leaflet-control-layers-base label:hover,
.leaflet-control-layers-overlays label:hover {
background-color: rgba(59, 130, 246, 0.08) !important;
}
.leaflet-control-layers input[type="radio"],
.leaflet-control-layers input[type="checkbox"] {
margin-right: 6px !important;
transform: scale(0.9) !important;
} }

View File

@@ -2,6 +2,8 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import Navigation from "@/components/ui/Navigation"; import Navigation from "@/components/ui/Navigation";
import { AuthProvider } from "@/components/auth/AuthProvider"; import { AuthProvider } from "@/components/auth/AuthProvider";
import { TranslationProvider } from "@/lib/i18n";
import { ThemeProvider } from "@/components/ThemeProvider";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -14,20 +16,24 @@ const geistMono = Geist_Mono({
}); });
export const metadata = { export const metadata = {
title: "Project Management Panel", title: "eProjektant Wastpol",
description: "Professional project management dashboard", description: "Panel Wastpol",
}; };
export default function RootLayout({ children }) { export default function RootLayout({ children }) {
return ( return (
<html lang="en"> <html lang="pl">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground`}
> >
<AuthProvider> <ThemeProvider>
<Navigation /> <TranslationProvider initialLanguage="pl">
<main>{children}</main> <AuthProvider>
</AuthProvider> <Navigation />
<main>{children}</main>
</AuthProvider>
</TranslationProvider>
</ThemeProvider>
</body> </body>
</html> </html>
); );

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,53 @@
import ProjectTasksList from "@/components/ProjectTasksList"; import ProjectTasksList from "@/components/ProjectTasksList";
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 Link from "next/link";
export default function ProjectTasksPage() { export default function ProjectTasksPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Project Tasks" title="Zadania"
description="View and manage tasks across all projects in a structured list format" description="Zarządzaj zadaniami projektów"
actions={[
<Link href="/tasks/templates" key="templates">
<Button variant="secondary" size="md">
<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="M4 7v10c0 2.21 1.79 4 4 4h8c2.21 0 4-1.79 4-4V7M4 7c0-2.21 1.79-4 4-4h8c2.21 0 4 1.79 4 4M4 7h16M9 11v4m6-4v4"
/>
</svg>
Szablony zadań
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="md">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
]}
/> />
<ProjectTasksList /> <ProjectTasksList />
</PageContainer> </PageContainer>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useRef } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import ProjectForm from "@/components/ProjectForm"; import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer"; import PageContainer from "@/components/ui/PageContainer";
@@ -8,6 +8,7 @@ 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 { LoadingState } from "@/components/ui/States"; import { LoadingState } from "@/components/ui/States";
import { useTranslation } from "@/lib/i18n";
export default function EditProjectPage() { export default function EditProjectPage() {
const params = useParams(); const params = useParams();
@@ -15,6 +16,8 @@ export default function EditProjectPage() {
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const { t } = useTranslation();
const formRef = useRef();
useEffect(() => { useEffect(() => {
const fetchProject = async () => { const fetchProject = async () => {
@@ -62,10 +65,30 @@ export default function EditProjectPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Edit Project" title={t('projects.editProject')}
description={`Editing: ${project.project_name || "Untitled Project"}`} description={`${t('projects.editing')}: ${project.project_name || "Untitled Project"}`}
action={ action={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button
variant="primary"
size="sm"
onClick={() => formRef.current?.saveProject()}
>
<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="M5 13l4 4L19 7"
/>
</svg>
{t('common.save')}
</Button>
<Link href={`/projects/${id}`}> <Link href={`/projects/${id}`}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<svg <svg
@@ -81,7 +104,7 @@ export default function EditProjectPage() {
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/> />
</svg> </svg>
Cancel {t('common.cancel')}
</Button> </Button>
</Link> </Link>
<Link href="/projects"> <Link href="/projects">
@@ -99,14 +122,14 @@ export default function EditProjectPage() {
d="M15 19l-7-7 7-7" d="M15 19l-7-7 7-7"
/> />
</svg> </svg>
Back to Projects {t('projects.backToProjects')}
</Button> </Button>
</Link> </Link>
</div> </div>
} }
/> />
<div className="max-w-2xl"> <div className="max-w-2xl">
<ProjectForm initialData={project} /> <ProjectForm ref={formRef} initialData={project} />
</div> </div>
</PageContainer> </PageContainer>
); );

View File

@@ -1,33 +1,209 @@
import { "use client";
getProjectWithContract,
getNotesForProject, import { useState, useEffect } from "react";
} from "@/lib/queries/projects"; import { useParams } from "next/navigation";
import { useSession } from "next-auth/react";
import NoteForm from "@/components/NoteForm"; import NoteForm from "@/components/NoteForm";
import ProjectTasksSection from "@/components/ProjectTasksSection"; import ProjectTasksSection from "@/components/ProjectTasksSection";
import FieldWithHistory from "@/components/FieldWithHistory";
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";
import Link from "next/link"; import Link from "next/link";
import { differenceInCalendarDays, parseISO } from "date-fns"; import { differenceInCalendarDays, parseISO } from "date-fns";
import { formatDate } from "@/lib/utils"; import { formatDate, formatCoordinates } from "@/lib/utils";
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 ProjectStatusDropdown from "@/components/ProjectStatusDropdown"; import ProjectStatusDropdown from "@/components/ProjectStatusDropdown";
import ProjectAssigneeDropdown from "@/components/ProjectAssigneeDropdown";
import ClientProjectMap from "@/components/ui/ClientProjectMap"; import ClientProjectMap from "@/components/ui/ClientProjectMap";
import FileUploadBox from "@/components/FileUploadBox";
import FileItem from "@/components/FileItem";
import proj4 from "proj4";
export default async function ProjectViewPage({ params }) { export default function ProjectViewPage() {
const { id } = await params; const params = useParams();
const project = await getProjectWithContract(id); const { data: session } = useSession();
const notes = await getNotesForProject(id); const [project, setProject] = useState(null);
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editText, setEditText] = useState('');
// Helper function to parse note text with links
const parseNoteText = (text) => {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
// Add text before the link
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// Add the link
parts.push(
<a
key={match.index}
href={match[2]}
className="text-blue-600 hover:text-blue-800 underline"
target="_blank"
rel="noopener noreferrer"
>
{match[1]}
</a>
);
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : text;
};
// Helper function to add a new note to the list
const addNote = (newNote) => {
setNotes(prevNotes => [newNote, ...prevNotes]);
};
// Helper function to check if user can modify a note (edit or delete)
const canModifyNote = (note) => {
if (!session?.user) return false;
// Admins can modify any note
if (session.user.role === 'admin') return true;
// Users can modify their own notes
return note.created_by === session.user.id;
};
// Helper function to handle file upload
const handleFileUploaded = (newFile) => {
setUploadedFiles(prevFiles => [newFile, ...prevFiles]);
};
// Helper function to handle file deletion
const handleFileDelete = async (fileId) => {
if (confirm('Czy na pewno chcesz usunąć ten plik?')) {
try {
const res = await fetch(`/api/files/${fileId}`, {
method: 'DELETE',
});
if (res.ok) {
setUploadedFiles(prevFiles => prevFiles.filter(file => file.file_id !== fileId));
} else {
alert('Błąd podczas usuwania pliku');
}
} catch (error) {
console.error('Error deleting file:', error);
alert('Błąd podczas usuwania pliku');
}
}
};
// Helper function to handle file update (edit)
const handleFileUpdate = async (updatedFile) => {
setUploadedFiles(prevFiles =>
prevFiles.map(file =>
file.file_id === updatedFile.file_id ? updatedFile : file
)
);
};
// Helper function to save edited note
const handleSaveNote = async (noteId) => {
try {
const res = await fetch(`/api/notes/${noteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ note: editText }),
});
if (res.ok) {
// Update the note in local state
setNotes(prevNotes =>
prevNotes.map(note =>
note.note_id === noteId ? { ...note, note: editText, edited_at: new Date().toISOString() } : note
)
);
setEditingNoteId(null);
setEditText('');
} else {
alert('Błąd podczas aktualizacji notatki');
}
} catch (error) {
console.error('Error updating note:', error);
alert('Błąd podczas aktualizacji notatki');
}
};
useEffect(() => {
const fetchData = async () => {
if (!params.id) return;
try {
// Fetch project data
const projectRes = await fetch(`/api/projects/${params.id}`);
if (!projectRes.ok) {
throw new Error('Project not found');
}
const projectData = await projectRes.json();
// Fetch notes data
const notesRes = await fetch(`/api/notes?project_id=${params.id}`);
const notesData = notesRes.ok ? await notesRes.json() : [];
// Fetch files data
const filesRes = await fetch(`/api/files?entityType=project&entityId=${params.id}`);
const filesData = filesRes.ok ? await filesRes.json() : [];
setProject(projectData);
setNotes(notesData);
setUploadedFiles(filesData);
} catch (error) {
console.error('Error fetching data:', error);
setProject(null);
setNotes([]);
} finally {
setLoading(false);
}
};
fetchData();
}, [params.id]);
useEffect(() => {
if (project?.project_name) {
document.title = `${project.project_name} - Panel`;
} else {
document.title = 'Panel';
}
}, [project]);
if (loading) {
return (
<PageContainer>
<div className="flex items-center justify-center py-12">
<div className="text-gray-500">Loading...</div>
</div>
</PageContainer>
);
}
if (!project) { if (!project) {
return ( return (
<PageContainer> <PageContainer>
<Card> <Card>
<CardContent className="text-center py-8"> <CardContent className="text-center py-8">
<p className="text-red-600 text-lg">Project not found.</p> <p className="text-red-600 text-lg">Projekt nie został znaleziony.</p>
<Link href="/projects" className="mt-4 inline-block"> <Link href="/projects" className="mt-4 inline-block">
<Button variant="primary">Back to Projects</Button> <Button variant="primary">Powrót do projektów</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -44,62 +220,140 @@ export default async function ProjectViewPage({ params }) {
if (days <= 7) return "warning"; if (days <= 7) return "warning";
return "success"; return "success";
}; };
return ( return (
<PageContainer> <PageContainer>
<PageHeader {/* Mobile: Full-width title, Desktop: Standard PageHeader */}
title={project.project_name} <div className="block sm:hidden mb-6">
description={`${project.city}${project.address}`} {/* Mobile Layout */}
action={ <div className="space-y-4">
<div className="flex items-center gap-3"> {/* Full-width title */}
<ProjectStatusDropdown project={project} size="sm" /> <div className="w-full">
{daysRemaining !== null && ( <h1 className="text-2xl font-bold text-gray-900 break-words">
<Badge variant={getDeadlineVariant(daysRemaining)} size="md"> {project.project_name}
{daysRemaining === 0 </h1>
? "Due Today" <p className="text-sm text-gray-600 mt-1">
: daysRemaining > 0 {project.city} {project.address} {project.project_number}
? `${daysRemaining} days left` </p>
: `${Math.abs(daysRemaining)} days overdue`}
</Badge>
)}
<Link href="/projects">
<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>
Back to Projects
</Button>
</Link>
<Link href={`/projects/${id}/edit`}>
<Button variant="primary">
<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>
Edit Project
</Button>
</Link>
</div> </div>
}
/>{" "} {/* Mobile action bar */}
<div className="flex flex-col space-y-3">
{/* Status and deadline badges */}
<div className="flex items-center gap-2 flex-wrap">
<ProjectStatusDropdown project={project} size="sm" />
{daysRemaining !== null && (
<Badge variant={getDeadlineVariant(daysRemaining)} size="sm" className="text-xs">
{daysRemaining === 0
? "Termin dzisiaj"
: daysRemaining > 0
? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} dni po terminie`}
</Badge>
)}
</div>
{/* Action buttons - full width */}
<div className="flex gap-2 w-full">
<Link href="/projects" className="flex-1">
<Button variant="outline" size="sm" className="w-full text-xs">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
Powrót
</Button>
</Link>
<Link href={`/projects/${params.id}/edit`} className="flex-1">
<Button variant="primary" size="sm" className="w-full text-xs">
<svg
className="w-4 h-4 mr-1"
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
</Button>
</Link>
</div>
</div>
</div>
</div>
{/* Desktop: Standard PageHeader */}
<div className="hidden sm:block">
<PageHeader
title={project.project_name}
description={`${project.city}${project.address}${project.project_number}`}
action={
<div className="flex items-center gap-3">
<ProjectStatusDropdown project={project} size="sm" />
{daysRemaining !== null && (
<Badge variant={getDeadlineVariant(daysRemaining)} size="md">
{daysRemaining === 0
? "Termin dzisiaj"
: daysRemaining > 0
? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} dni po terminie`}
</Badge>
)}
<Link href="/projects">
<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>
Powrót do projektów
</Button>
</Link>
<Link href={`/projects/${params.id}/edit`}>
<Button variant="primary">
<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 projekt
</Button>
</Link>
</div>
}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
{/* Main Project Information */} {/* Main Project Information */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
@@ -108,7 +362,7 @@ export default async function ProjectViewPage({ params }) {
{" "} {" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Project Information Informacje o projekcie
</h2> </h2>
<Badge <Badge
variant={ variant={
@@ -123,12 +377,12 @@ export default async function ProjectViewPage({ params }) {
size="sm" size="sm"
> >
{project.project_type === "design" {project.project_type === "design"
? "Design (P)" ? "Projektowanie (P)"
: project.project_type === "construction" : project.project_type === "construction"
? "Construction (R)" ? "Budowa (B)"
: project.project_type === "design+construction" : project.project_type === "design+construction"
? "Design + Construction (P+R)" ? "Projektowanie + Budowa (P+B)"
: "Unknown"} : "Nieznany"}
</Badge> </Badge>
</div> </div>
</CardHeader> </CardHeader>
@@ -136,7 +390,7 @@ export default async function ProjectViewPage({ params }) {
<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">
Location Lokalizacja
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.city || "N/A"} {project.city || "N/A"}
@@ -144,7 +398,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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">
Address Adres
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.address || "N/A"} {project.address || "N/A"}
@@ -152,7 +406,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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">
Plot Działka
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.plot || "N/A"} {project.plot || "N/A"}
@@ -160,7 +414,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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">
District Jednostka ewidencyjna
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.district || "N/A"} {project.district || "N/A"}
@@ -168,22 +422,29 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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">
Unit Obręb
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.unit || "N/A"} {project.unit || "N/A"}
</p> </p>
</div>{" "} </div>{" "}
<div> <FieldWithHistory
<span className="text-sm font-medium text-gray-500 block mb-1"> tableName="projects"
Deadline recordId={project.project_id}
</span> fieldName="finish_date"
<p className="text-gray-900 font-medium"> currentValue={project.finish_date}
{project.finish_date label="Termin zakończenia"
? formatDate(project.finish_date) />
: "N/A"} {project.completion_date && (
</p> <div>
</div> <span className="text-sm font-medium text-gray-500 block mb-1">
Data zakończenia projektu
</span>
<p className="text-gray-900 font-medium">
{formatDate(project.completion_date)}
</p>
</div>
)}
<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">
WP WP
@@ -194,35 +455,110 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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">
Investment Number Numer inwestycji
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.investment_number || "N/A"} {project.investment_number || "N/A"}
</p> </p>
</div> </div>
{session?.user?.role === 'team_lead' && project.wartosc_zlecenia && (
<FieldWithHistory
tableName="projects"
recordId={project.project_id}
fieldName="wartosc_zlecenia"
currentValue={project.wartosc_zlecenia}
displayValue={parseFloat(project.wartosc_zlecenia).toLocaleString('pl-PL', {
style: 'currency',
currency: 'PLN'
})}
label="Wartość zlecenia"
/>
)}
</div> </div>
{project.contact && ( {project.contact && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Contact Kontakt
</span> </span>
<p className="text-gray-900 font-medium">{project.contact}</p> <p className="text-gray-900 font-medium">{project.contact}</p>
</div> </div>
)} )}
{project.coordinates && ( {project.coordinates && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Coordinates Współrzędne
</span> </span>
<div className="flex items-center gap-2">
<p className="text-gray-900 font-medium font-mono text-sm"> <p className="text-gray-900 font-medium font-mono text-sm">
{project.coordinates} {formatCoordinates(project.coordinates)}
</p> </p>
<a
href={`https://www.google.com/maps/place/${project.coordinates}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 transition-colors"
title="Otwórz w Google Maps"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</a>
<a
href={(() => {
// Define EPSG:2180 projection (Poland CS92)
proj4.defs("EPSG:2180", "+proj=tmerc +lat_0=0 +lon_0=19 +k=0.9993 +x_0=500000 +y_0=-5300000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs");
const [lat, lng] = project.coordinates.split(',').map(c => parseFloat(c.trim()));
// Convert WGS84 to EPSG:2180
const [x, y] = proj4('EPSG:4326', 'EPSG:2180', [lng, lat]);
// Create bbox with ~100m offset in each direction
const offset = 100;
const bbox = `${x - offset},${y - offset},${x + offset},${y + offset}`;
return `https://mapy.geoportal.gov.pl/imap/Imgp_2.html?gpmap=gp0&bbox=${bbox}&variant=KATASTER`;
})()}
target="_blank"
rel="noopener noreferrer"
className="text-green-600 hover:text-green-800 transition-colors"
title="Otwórz w Geoportal"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</a>
</div> </div>
)} </div>
)} {project.notes && (
{project.notes && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-1"> <span className="text-sm font-medium text-gray-500 block mb-1">
Notes Notes
@@ -237,14 +573,14 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Contract Details Szczegóły umowy
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<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 Numer umowy
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.contract_number || "N/A"} {project.contract_number || "N/A"}
@@ -252,15 +588,24 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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 Numer umowy klienta
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.contract_name || "N/A"} {project.customer_contract_number ? (
<Link
href={`/contracts/${project.contract_id}`}
className="text-inherit hover:text-inherit no-underline"
>
{project.customer_contract_number}
</Link>
) : (
"N/A"
)}
</p> </p>
</div> </div>
<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 Klient
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.customer || "N/A"} {project.customer || "N/A"}
@@ -268,7 +613,7 @@ export default async function ProjectViewPage({ params }) {
</div> </div>
<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 Inwestor
</span> </span>
<p className="text-gray-900 font-medium"> <p className="text-gray-900 font-medium">
{project.investor || "N/A"} {project.investor || "N/A"}
@@ -284,21 +629,27 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Project Status Status projektu
</h2> </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">
Current Status Aktualny status
</span> </span>
<ProjectStatusDropdown project={project} size="md" /> <ProjectStatusDropdown project={project} size="md" />
</div> </div>
<div className="border-t pt-4">
<span className="text-sm font-medium text-gray-500 block mb-2">
Przypisany do
</span>
<ProjectAssigneeDropdown project={project} size="md" />
</div>
{daysRemaining !== null && ( {daysRemaining !== null && (
<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">
Timeline Harmonogram
</span> </span>
<div className="text-center"> <div className="text-center">
<Badge <Badge
@@ -306,10 +657,10 @@ export default async function ProjectViewPage({ params }) {
size="lg" size="lg"
> >
{daysRemaining === 0 {daysRemaining === 0
? "Due Today" ? "Termin dzisiaj"
: daysRemaining > 0 : daysRemaining > 0
? `${daysRemaining} days remaining` ? `${daysRemaining} dni pozostało`
: `${Math.abs(daysRemaining)} days overdue`} : `${Math.abs(daysRemaining)} dni po terminie`}
</Badge> </Badge>
</div> </div>
</div> </div>
@@ -321,11 +672,11 @@ export default async function ProjectViewPage({ params }) {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Quick Actions Szybkie akcje
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<Link href={`/projects/${id}/edit`} className="block"> <Link href={`/projects/${params.id}/edit`} className="block">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -344,13 +695,13 @@ export default async function ProjectViewPage({ params }) {
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" 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> </svg>
Edit Project Edytuj projekt
</Button> </Button>
</Link>{" "} </Link>{" "}
<Link href="/projects" className="block"> <Link href="/projects" className="block">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full justify-start" className="w-full justify-start"
> >
<svg <svg
@@ -366,7 +717,7 @@ export default async function ProjectViewPage({ params }) {
d="M15 19l-7-7 7-7" d="M15 19l-7-7 7-7"
/> />
</svg> </svg>
Back to Projects Powrót do projektów
</Button> </Button>
</Link> </Link>
<Link href="/projects/map" className="block"> <Link href="/projects/map" className="block">
@@ -388,13 +739,38 @@ export default async function ProjectViewPage({ params }) {
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/> />
</svg> </svg>
View All on Map Zobacz wszystkie na mapie
</Button> </Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
{/* File Upload */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900">
Załączniki
</h2>
</CardHeader>
<CardContent className="space-y-4">
<FileUploadBox projectId={params.id} onFileUploaded={handleFileUploaded} />
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700">Przesłane pliki:</h3>
{uploadedFiles.map((file) => (
<FileItem
key={file.file_id}
file={file}
onDelete={handleFileDelete}
onUpdate={handleFileUpdate}
/>
))}
</div>
)}
</CardContent>
</Card>
</div> </div>
</div>{" "} </div>
{/* Project Location Map */} {/* Project Location Map */}
{project.coordinates && ( {project.coordinates && (
<div className="mb-8"> <div className="mb-8">
@@ -404,7 +780,7 @@ export default async function ProjectViewPage({ params }) {
{" "} {" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Project Location Lokalizacja projektu
</h2> </h2>
{project.coordinates && ( {project.coordinates && (
<Link <Link
@@ -428,7 +804,7 @@ export default async function ProjectViewPage({ params }) {
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/> />
</svg> </svg>
View on Full Map Zobacz na pełnej mapie
</Button> </Button>
</Link> </Link>
)} )}
@@ -442,6 +818,7 @@ export default async function ProjectViewPage({ params }) {
showLayerControl={true} showLayerControl={true}
mapHeight="h-80" mapHeight="h-80"
defaultLayer="Polish Geoportal Orthophoto" defaultLayer="Polish Geoportal Orthophoto"
showOverlays={false}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -449,36 +826,39 @@ export default async function ProjectViewPage({ params }) {
)} )}
{/* Project Tasks Section */} {/* Project Tasks Section */}
<div className="mb-8"> <div className="mb-8">
<ProjectTasksSection projectId={id} /> <ProjectTasksSection projectId={params.id} />
</div> </div>
{/* Notes Section */} {/* Notes Section */}
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900">Notes</h2> <h2 className="text-xl font-semibold text-gray-900">Notatki</h2>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-6"> <div className="mb-6">
<NoteForm projectId={id} /> <NoteForm projectId={params.id} onNoteAdded={addNote} />
</div> </div>
{notes.length === 0 ? ( {notes.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-400 mb-4"> <div className="text-gray-400 mb-4">
<svg <svg
className="w-12 h-12 mx-auto" className="w-12 h-12 mx-auto"
fill="currentColor" fill="none"
viewBox="0 0 20 20" stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <path
fillRule="evenodd" strokeLinecap="round"
d="M4 4a2 2 0 012-2h8a2 2 0 012 2v12a1 1 0 110 2h-3a1 1 0 01-1-1v-1H8v1a1 1 0 01-1 1H4a1 1 0 110-2V4zm3 1h2v4a1 1 0 001 1h1a1 1 0 100-2v-1a2 2 0 00-2-2H7a1 1 0 000 2z" strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/> />
</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 notes yet Brak notatek
</h3> </h3>
<p className="text-gray-500"> <p className="text-gray-500">
Add your first note using the form above. Dodaj swoją pierwszą notatkę używając formularza powyżej.
</p> </p>
</div> </div>
) : ( ) : (
@@ -486,21 +866,121 @@ export default async function ProjectViewPage({ params }) {
{notes.map((n) => ( {notes.map((n) => (
<div <div
key={n.note_id} key={n.note_id}
className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors" className="border border-gray-200 p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors group"
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-500"> <span className="text-sm font-medium text-gray-500">
{n.note_date} {formatDate(n.note_date, { includeTime: true })}
</span> </span>
{n.created_by_name && ( {n.created_by_name && (
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium"> <span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full font-medium">
{n.created_by_name} {n.created_by_name}
</span> </span>
)} )}
{n.edited_at && (
<span className="text-xs text-gray-400 italic">
edytowane
</span>
)}
</div> </div>
{canModifyNote(n) && (
<div className="flex gap-1">
<button
onClick={() => {
setEditingNoteId(n.note_id);
setEditText(n.note);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded"
title="Edytuj notatkę"
>
<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
onClick={async () => {
if (confirm('Czy na pewno chcesz usunąć tę notatkę?')) {
try {
const res = await fetch(`/api/notes/${n.note_id}`, {
method: 'DELETE',
});
if (res.ok) {
// Remove the note from local state instead of full page reload
setNotes(prevNotes => prevNotes.filter(note => note.note_id !== n.note_id));
} else {
alert('Błąd podczas usuwania notatki');
}
} catch (error) {
console.error('Error deleting note:', error);
alert('Błąd podczas usuwania notatki');
}
}
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded"
title="Usuń notatkę"
>
<svg
className="w-4 h-4"
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> </div>
<p className="text-gray-900 leading-relaxed">{n.note}</p> {editingNoteId === n.note_id ? (
<div className="space-y-2">
<textarea
value={editText}
onChange={(e) => setEditText(e.target.value)}
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={3}
placeholder="Wpisz notatkę..."
/>
<div className="flex gap-2">
<Button
onClick={() => handleSaveNote(n.note_id)}
variant="primary"
size="sm"
>
Zapisz
</Button>
<Button
onClick={() => {
setEditingNoteId(null);
setEditText('');
}}
variant="outline"
size="sm"
>
Anuluj
</Button>
</div>
</div>
) : (
<div className="text-gray-900 leading-relaxed">
{parseNoteText(n.note)}
</div>
)}
</div> </div>
))} ))}
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,6 @@
"use client";
import { useTranslation } from "@/lib/i18n";
import ProjectForm from "@/components/ProjectForm"; import ProjectForm from "@/components/ProjectForm";
import PageContainer from "@/components/ui/PageContainer"; import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader"; import PageHeader from "@/components/ui/PageHeader";
@@ -5,11 +8,13 @@ import Button from "@/components/ui/Button";
import Link from "next/link"; import Link from "next/link";
export default function NewProjectPage() { export default function NewProjectPage() {
const { t } = useTranslation();
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Create New Project" title={t("projects.newProject")}
description="Add a new project to your portfolio" // description={t("projects.noProjectsMessage")}
action={ action={
<Link href="/projects"> <Link href="/projects">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
@@ -26,7 +31,7 @@ export default function NewProjectPage() {
d="M15 19l-7-7 7-7" d="M15 19l-7-7 7-7"
/> />
</svg> </svg>
Back to Projects {t("common.back")}
</Button> </Button>
</Link> </Link>
} }

View File

@@ -11,41 +11,125 @@ import PageHeader from "@/components/ui/PageHeader";
import SearchBar from "@/components/ui/SearchBar"; import SearchBar from "@/components/ui/SearchBar";
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";
import { useSession } from "next-auth/react";
export default function ProjectListPage() { export default function ProjectListPage() {
const { t } = useTranslation();
const { data: session } = useSession();
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [filteredProjects, setFilteredProjects] = useState([]); const [filteredProjects, setFilteredProjects] = useState([]);
const [filters, setFilters] = useState({
status: 'all',
type: 'all',
customer: 'all',
mine: false,
phoneOnly: false
});
const [customers, setCustomers] = useState([]);
// Load phoneOnly filter from localStorage after mount to avoid hydration issues
useEffect(() => {
const savedPhoneOnly = localStorage.getItem('projectsPhoneOnlyFilter') === 'true';
if (savedPhoneOnly) {
setFilters(prev => ({
...prev,
phoneOnly: savedPhoneOnly
}));
}
}, []);
const [filtersExpanded, setFiltersExpanded] = useState(true); // Start expanded on mobile so users know filters exist
const [searchMatchType, setSearchMatchType] = useState(null); // Track what type of match was found
// Helper function to normalize strings by removing spaces
const normalizeString = (str) => {
return str?.replace(/\s+/g, '') || '';
};
useEffect(() => { useEffect(() => {
fetch("/api/projects") fetch("/api/projects")
.then((res) => res.json()) .then((res) => res.json())
.then((data) => { .then((data) => {
setProjects(data); setProjects(data);
setFilteredProjects(data); setFilteredProjects(data);
// Extract unique customers for filter
const uniqueCustomers = [...new Set(data.map(p => p.customer).filter(Boolean))];
setCustomers(uniqueCustomers);
}); });
}, []); }, []);
// Filter projects based on search term // Filter projects based on search term and filters
useEffect(() => { useEffect(() => {
if (!searchTerm.trim()) { let filtered = projects;
setFilteredProjects(projects);
} else { // Apply status filter
const filtered = projects.filter((project) => { if (filters.status !== 'all') {
const searchLower = searchTerm.toLowerCase(); if (filters.status === 'not_finished') {
return ( filtered = filtered.filter(project => project.project_status !== 'fulfilled');
} else {
filtered = filtered.filter(project => project.project_status === filters.status);
}
}
// Apply type filter
if (filters.type !== 'all') {
filtered = filtered.filter(project => project.project_type === filters.type);
}
// Apply customer filter
if (filters.customer !== 'all') {
filtered = filtered.filter(project => project.customer === filters.customer);
}
// Apply mine filter
if (filters.mine && session?.user?.id) {
filtered = filtered.filter(project => project.assigned_to === session.user.id);
}
// Apply search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
const searchNormalized = normalizeString(searchLower);
let hasContactMatch = false;
filtered = filtered.map((project) => {
const isContactMatch = normalizeString(project.contact?.toLowerCase()).includes(searchNormalized);
if (isContactMatch) hasContactMatch = true;
// Add a flag to mark projects that matched on contact
return {
...project,
_matchedOnContact: isContactMatch
};
}).filter((project) => {
const baseMatches =
project.project_name?.toLowerCase().includes(searchLower) || project.project_name?.toLowerCase().includes(searchLower) ||
project.wp?.toLowerCase().includes(searchLower) || project.wp?.toLowerCase().includes(searchLower) ||
project.plot?.toLowerCase().includes(searchLower) || project.plot?.toLowerCase().includes(searchLower) ||
project.investment_number?.toLowerCase().includes(searchLower) || project.investment_number?.toLowerCase().includes(searchLower) ||
project.address?.toLowerCase().includes(searchLower) project.address?.toLowerCase().includes(searchLower) ||
); project.customer?.toLowerCase().includes(searchLower) ||
project.investor?.toLowerCase().includes(searchLower);
// Include contact matches only if phoneOnly is enabled
const contactMatch = filters.phoneOnly ? project._matchedOnContact : false;
return baseMatches || contactMatch;
}); });
setFilteredProjects(filtered);
setSearchMatchType(hasContactMatch ? 'contact' : null);
} else {
setSearchMatchType(null);
} }
}, [searchTerm, projects]);
setFilteredProjects(filtered);
}, [searchTerm, projects, filters, session]);
async function handleDelete(id) { async function handleDelete(id) {
const confirmed = confirm("Are you sure you want to delete this project?"); const confirmed = confirm(t('projects.deleteConfirm'));
if (!confirmed) return; if (!confirmed) return;
const res = await fetch(`/api/projects/${id}`, { const res = await fetch(`/api/projects/${id}`, {
@@ -59,32 +143,100 @@ export default function ProjectListPage() {
const handleSearchChange = (e) => { const handleSearchChange = (e) => {
setSearchTerm(e.target.value); setSearchTerm(e.target.value);
}; };
const handleFilterChange = (filterType, value) => {
setFilters(prev => {
const newFilters = {
...prev,
[filterType]: value
};
// Save phoneOnly filter to localStorage
if (filterType === 'phoneOnly') {
localStorage.setItem('projectsPhoneOnlyFilter', value.toString());
}
return newFilters;
});
};
const clearAllFilters = () => {
setFilters(prev => ({
status: 'all',
type: 'all',
customer: 'all',
mine: false,
phoneOnly: prev.phoneOnly // Preserve phone toggle state
}));
setSearchTerm('');
};
const handleExportExcel = async () => {
try {
const response = await fetch('/api/projects/export');
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `projects_export_${new Date().toISOString().split('T')[0]}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} else {
alert('Failed to export projects. Please try again.');
}
} catch (error) {
console.error('Export error:', error);
alert('An error occurred during export. Please try again.');
}
};
const toggleFilters = () => {
setFiltersExpanded(!filtersExpanded);
};
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm.trim() !== '';
const getActiveFilterCount = () => {
let count = 0;
if (filters.status !== 'all') count++;
if (filters.type !== 'all') count++;
if (filters.customer !== 'all') count++;
if (filters.mine) count++;
if (searchTerm.trim()) count++;
return count;
};
const getStatusLabel = (status) => {
switch(status) {
case "registered": return t('projectStatus.registered');
case "in_progress_design": return t('projectStatus.in_progress_design');
case "in_progress_construction": return t('projectStatus.in_progress_construction');
case "fulfilled": return t('projectStatus.fulfilled');
case "cancelled": return t('projectStatus.cancelled');
default: return "-";
}
};
const getTypeLabel = (type) => {
switch(type) {
case "design": return t('projectType.design');
case "construction": return t('projectType.construction');
case "design+construction": return t('projectType.design+construction');
default: return "-";
}
};
return ( return (
<PageContainer> <PageContainer>
<PageHeader title="Projects" description="Manage and track your projects"> <PageHeader title={t('projects.title')} description={t('projects.subtitle')}>
<div className="flex gap-2"> <div className="flex items-center gap-2 sm:gap-3">
<Link href="/projects/map"> {/* Primary Action - New Project */}
<Button variant="outline" size="lg"> <Link href="/projects/new" className="flex-shrink-0">
<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="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
Map View
</Button>
</Link>
<Link href="/projects/new">
<Button variant="primary" size="lg"> <Button variant="primary" size="lg">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 sm:mr-2"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -96,19 +248,378 @@ export default function ProjectListPage() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Add Project <span className="hidden sm:inline">{t('projects.newProject')}</span>
</Button> </Button>
</Link> </Link>
{/* Spacer */}
<div className="flex-1"></div>
{/* Utility Actions - Icon Buttons */}
<div className="flex items-center gap-2">
<Link href="/projects/map" title={t('projects.mapView') || 'Widok mapy'}>
<Button variant="ghost" size="icon" className="h-10 w-10">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
</Button>
</Link>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={handleExportExcel}
title={t('projects.exportExcel') || 'Export to Excel'}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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>
</div> </div>
</PageHeader> </PageHeader>
<SearchBar <SearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
placeholder="Search by project name, WP, plot, or investment number..." placeholder={t('projects.searchPlaceholder')}
resultsCount={filteredProjects.length} resultsCount={filteredProjects.length}
resultsText="projects" resultsText={t('projects.projects') || 'projektów'}
/> />
{/* Filters */}
<Card className="mb-6">
{/* Mobile collapsible header */}
<div
className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 transition-colors md:hidden"
onClick={toggleFilters}
>
<div className="flex items-center space-x-3">
<svg
className={`w-5 h-5 text-gray-500 transition-transform duration-200 ${filtersExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
<h3 className="text-sm font-medium text-gray-900">
{t('common.filters') || 'Filtry'}
{hasActiveFilters && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{getActiveFilterCount()}
</span>
)}
</h3>
</div>
<div className="flex items-center space-x-2">
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
clearAllFilters();
}}
className="text-xs"
>
{t('common.clearAll') || 'Wyczyść'}
</Button>
)}
<div className="text-sm text-gray-500">
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
</div>
</div>
</div>
{/* Mobile collapsible content */}
<div className={`overflow-hidden transition-all duration-300 ease-in-out md:hidden ${filtersExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="px-4 pb-4 border-t border-gray-100">
<div className="flex flex-col space-y-4 md:flex-row md:flex-wrap md:gap-4 md:space-y-0 md:items-center pt-4">
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex flex-col space-y-2 md:flex-row md:items-center md:space-y-0 md:space-x-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 md:text-xs md:whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 md:px-3 md:py-1 md:text-sm"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={`
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
>
<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>
</button>
{session?.user && (
<button
onClick={() => handleFilterChange('mine', !filters.mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${filters.mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
</div>
</div>
</div>
{/* Desktop always visible content */}
<div className="hidden md:block">
<div className="p-4">
<div className="flex flex-col space-y-3">
{/* Filters row */}
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('common.status') || 'Status'}:
</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
<option value="not_finished">{t('projects.notFinished') || 'Nie zakończone'}</option>
<option value="registered">{t('projectStatus.registered')}</option>
<option value="in_progress_design">{t('projectStatus.in_progress_design')}</option>
<option value="in_progress_construction">{t('projectStatus.in_progress_construction')}</option>
<option value="fulfilled">{t('projectStatus.fulfilled')}</option>
</select>
</div>
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('common.type') || 'Typ'}:
</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
<option value="design">{t('projectType.design')}</option>
<option value="construction">{t('projectType.construction')}</option>
<option value="design+construction">{t('projectType.design+construction')}</option>
</select>
</div>
<div className="flex items-center space-x-2">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('contracts.customer') || 'Klient'}:
</label>
<select
value={filters.customer}
onChange={(e) => handleFilterChange('customer', e.target.value)}
className="px-3 py-1 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">{t('common.all')}</option>
{customers.map((customer) => (
<option key={customer} value={customer}>
{customer}
</option>
))}
</select>
</div>
<button
onClick={() => handleFilterChange('phoneOnly', !filters.phoneOnly)}
className={`
inline-flex items-center justify-center w-9 h-9 rounded-full text-sm font-medium transition-all
${filters.phoneOnly
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
title={filters.phoneOnly ? (t('projects.phoneSearchEnabled') || 'Wyszukiwanie po numerze włączone') : (t('projects.phoneSearchDisabled') || 'Wyszukiwanie po numerze wyłączone')}
>
<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>
</button>
{session?.user && (
<button
onClick={() => handleFilterChange('mine', !filters.mine)}
className={`
inline-flex items-center space-x-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all
${filters.mine
? 'bg-blue-100 text-blue-700 border-2 border-blue-300 dark:bg-blue-900/30 dark:text-blue-300 dark:border-blue-700'
: 'bg-gray-100 text-gray-700 border-2 border-gray-200 hover:border-gray-300 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
}
`}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
<span>{t('projects.mine') || 'Tylko moje'}</span>
</button>
)}
</div>
{/* Results and clear button row */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div className="text-sm text-gray-500">
{t('projects.showingResults', { shown: filteredProjects.length, total: projects.length }) || `Wyświetlono ${filteredProjects.length} z ${projects.length} projektów`}
</div>
{(filters.status !== 'all' || filters.type !== 'all' || filters.customer !== 'all' || filters.mine || searchTerm) && (
<Button
variant="ghost"
size="sm"
onClick={clearAllFilters}
className="text-xs"
>
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{t('common.clearAllFilters') || 'Wyczyść wszystkie filtry'}
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
{filteredProjects.length === 0 && searchTerm ? ( {filteredProjects.length === 0 && searchTerm ? (
<Card> <Card>
<CardContent className="text-center py-12"> <CardContent className="text-center py-12">
@@ -126,14 +637,13 @@ export default function ProjectListPage() {
</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 found {t('common.noResults')}
</h3> </h3>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
No projects match your search criteria. Try adjusting your search {t('projects.noMatchingResults') || 'Brak projektów pasujących do kryteriów wyszukiwania. Spróbuj zmienić wyszukiwane frazy.'}
terms.
</p> </p>
<Button variant="outline" onClick={() => setSearchTerm("")}> <Button variant="outline" onClick={() => setSearchTerm("")}>
Clear Search {t('common.clearSearch') || 'Wyczyść wyszukiwanie'}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@@ -154,160 +664,170 @@ export default function ProjectListPage() {
</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('projects.noProjects')}
</h3> </h3>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
Get started by creating your first project {t('projects.noProjectsMessage')}
</p> </p>
<Link href="/projects/new"> <Link href="/projects/new">
<Button variant="primary">Create First Project</Button> <Button variant="primary">{t('projects.createFirstProject') || 'Utwórz pierwszy projekt'}</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="w-full table-fixed"> {/* Mobile scroll container */}
<thead> <div className="overflow-x-auto">
<tr className="bg-gray-100 border-b"> <table className="w-full min-w-[600px] table-fixed">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-32"> <thead>
No. <tr className="bg-gray-100 dark:bg-gray-700 border-b dark:border-gray-600">
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700"> Nr.
Project Name </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-[200px] md:w-[250px]">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40"> {t('projects.projectName')}
WP </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-20 md:w-24 hidden lg:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> {t('projects.address')}
City </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-16 md:w-20 hidden sm:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-40"> WP
Address </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden md:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> {t('projects.city')}
Plot </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16 hidden sm:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('projects.plot')}
Finish </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-18 md:w-20 hidden md:table-cell">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-12"> {t('projects.finishDate')}
Type </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('common.type') || 'Typ'}
Status </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-10">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('common.status') || 'Status'}
Created By </th>
</th> <th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 dark:text-gray-300 w-14 md:w-16">
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-24"> {t('projects.assigned') || 'Przypisany'}
Assigned To </th>
</th> </tr>
<th className="text-left px-2 py-3 font-semibold text-xs text-gray-700 w-20"> </thead>
Actions
</th>
</tr>
</thead>
<tbody> <tbody>
{filteredProjects.map((project, index) => ( {filteredProjects.map((project, index) => (
<tr <tr
key={project.project_id} key={project.project_id}
className={`border-b hover:bg-gray-50 transition-colors ${ className={`border-b dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors ${
index % 2 === 0 ? "bg-white" : "bg-gray-25" index % 2 === 0 ? "bg-white dark:bg-gray-800" : "bg-gray-25 dark:bg-gray-750"
}`} } ${project._matchedOnContact && filters.phoneOnly ? 'border-l-4 border-l-blue-500' : ''}`}
> >
<td className="px-2 py-3"> <td className="px-1 py-3">
<Badge variant="primary" size="sm" className="text-xs"> <div className="flex items-center gap-1">
<Badge variant="secondary" size="sm" className="text-xs">
{project.project_number} {project.project_number}
</Badge> </Badge>
</td> {project._matchedOnContact && filters.phoneOnly && (
<td className="px-2 py-3"> <span
className="inline-flex items-center justify-center w-5 h-5 bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 rounded-full"
title={t('projects.contactNumberMatch') || 'Znaleziono numer kontaktowy'}
>
<svg className="w-3 h-3" 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>
</span>
)}
</div>
</td>
<td className="px-2 py-3 w-[200px] md:w-[250px]">
<Link <Link
href={`/projects/${project.project_id}`} href={`/projects/${project.project_id}`}
className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block" className="font-medium text-blue-600 hover:text-blue-800 transition-colors text-sm truncate block"
title={project.project_name} title={project.project_name}
> >
{project.project_name} <span className="block sm:hidden">
{project.project_name.length > 20
? `${project.project_name.substring(0, 20)}...`
: project.project_name}
</span>
<span className="hidden sm:block">
{project.project_name}
</span>
</Link> </Link>
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden lg:table-cell"
title={project.wp}
>
{project.wp || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.city}
>
{project.city || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.address} title={project.address}
> >
{project.address || "N/A"} {project.address || "N/A"}
</td> </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.wp}
>
{project.wp || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.city}
>
{project.city || "N/A"}
</td>
<td
className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden sm:table-cell"
title={project.plot} title={project.plot}
> >
{project.plot || "N/A"} {project.plot || "N/A"}
</td>{" "} </td>
<td <td
className="px-2 py-3 text-xs text-gray-600 truncate" className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 truncate hidden md:table-cell"
title={project.finish_date} title={project.finish_date}
> >
{project.finish_date {project.finish_date
? formatDate(project.finish_date) ? formatDate(project.finish_date)
: "N/A"} : "N/A"}
</td> </td>
<td className="px-2 py-3 text-xs text-gray-600 text-center font-semibold"> <td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400 text-center font-semibold">
{project.project_type === "design" {project.project_type === "design"
? "P" ? "P"
: project.project_type === "construction" : project.project_type === "construction"
? "R" ? "B"
: project.project_type === "design+construction" : project.project_type === "design+construction"
? "P+R" ? "P+B"
: "-"} : "-"}
</td> </td>
<td className="px-2 py-3 text-xs text-gray-600 truncate"> <td className="px-2 py-3 text-xs text-gray-600">
{project.project_status === "registered" <div className="flex justify-center items-center h-full">
? "Zarejestr." {project.project_status === 'registered' ? (
: project.project_status === "in_progress_design" <span className="text-red-500 font-bold text-sm" title={t('projectStatus.registered')}>N</span>
? "W real. (P)" ) : project.project_status === 'in_progress_design' ? (
: project.project_status === "in_progress_construction" <span className="inline-block w-3 h-3 bg-blue-500 rounded-full" title={t('projectStatus.in_progress_design')}></span>
? "W real. (R)" ) : project.project_status === 'in_progress_construction' ? (
: project.project_status === "fulfilled" <span className="inline-block w-3 h-3 bg-yellow-400 rounded-full" title={t('projectStatus.in_progress_construction')}></span>
? "Zakończony" ) : project.project_status === 'fulfilled' ? (
: "-"} <span className="inline-block w-3 h-3 bg-green-500 rounded-full" title={t('projectStatus.fulfilled')}></span>
</td> ) : project.project_status === 'cancelled' ? (
<td <span className="text-red-500 font-bold text-lg" title={t('projectStatus.cancelled')}>×</span>
className="px-2 py-3 text-xs text-gray-600 truncate" ) : (
title={project.created_by_name || "Unknown"} <span title="Unknown status">-</span>
> )}
{project.created_by_name || "Unknown"} </div>
</td>
<td
className="px-2 py-3 text-xs text-gray-600 truncate"
title={project.assigned_to_name || "Unassigned"}
>
{project.assigned_to_name || "Unassigned"}
</td> </td>
<td className="px-2 py-3"> <td className="px-2 py-3">
<Link href={`/projects/${project.project_id}`}> {project.assigned_to_initial ? (
<Button <div className="flex items-center justify-center">
variant="outline" <span className="inline-flex items-center justify-center w-8 h-8 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs font-semibold">
size="sm" {project.assigned_to_initial}
className="text-xs px-2 py-1" </span>
> </div>
View ) : (
</Button> <span className="text-xs text-gray-400">-</span>
</Link> )}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
)} )}
</PageContainer> </PageContainer>

77
src/app/settings/page.js Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import { useTranslation } from "@/lib/i18n";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import ThemeToggle from "@/components/ui/ThemeToggle";
import LanguageSwitcher from "@/components/ui/LanguageSwitcher";
import PasswordReset from "@/components/settings/PasswordReset";
export default function SettingsPage() {
const { t } = useTranslation();
return (
<PageContainer>
<PageHeader title={t('settings.title') || 'Settings'} />
<div className="max-w-2xl mx-auto space-y-6">
{/* Appearance Settings */}
<Card className="hidden">
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.appearance') || 'Appearance'}
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.theme') || 'Theme'}
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.themeDescription') || 'Choose your preferred theme'}
</p>
</div>
<ThemeToggle />
</div>
</CardContent>
</Card>
{/* Language Settings */}
<Card className="hidden">
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.language') || 'Language'}
</h2>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('settings.language') || 'Language'}
</label>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('settings.languageDescription') || 'Select your preferred language'}
</p>
</div>
<LanguageSwitcher />
</div>
</CardContent>
</Card>
{/* Password Settings */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('settings.password') || 'Password'}
</h2>
</CardHeader>
<CardContent>
<PasswordReset />
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,245 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function ApplyTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const params = useParams();
const setId = params.id;
const [taskSet, setTaskSet] = useState(null);
const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState("");
const [loading, setLoading] = useState(true);
const [isApplying, setIsApplying] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch task set
const setResponse = await fetch(`/api/task-sets/${setId}`);
if (setResponse.ok) {
const setData = await setResponse.json();
setTaskSet(setData);
} else {
console.error('Failed to fetch task set');
router.push('/task-sets');
return;
}
// Fetch projects
const projectsResponse = await fetch('/api/projects');
if (projectsResponse.ok) {
const projectsData = await projectsResponse.json();
setProjects(projectsData);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
if (setId) {
fetchData();
}
}, [setId, router]);
const handleApply = async () => {
if (!selectedProject) {
alert("Wybierz projekt");
return;
}
setIsApplying(true);
try {
const response = await fetch(`/api/task-sets/${setId}/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: parseInt(selectedProject) })
});
if (response.ok) {
const result = await response.json();
alert(`Zestaw zadań został pomyślnie zastosowany. Utworzono ${result.createdTaskIds.length} zadań.`);
router.push(`/projects/${selectedProject}`);
} else {
const error = await response.json();
alert(`Błąd: ${error.details || 'Nie udało się zastosować zestawu zadań'}`);
}
} catch (error) {
console.error('Error applying task set:', error);
alert('Wystąpił błąd podczas stosowania zestawu zadań');
} finally {
setIsApplying(false);
}
};
if (loading) {
return (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Zastosuj zestaw zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zastosuj zestaw zadań"
description={`Zastosuj zestaw "${taskSet.name}" do projektu`}
/>
<div className="max-w-4xl">
{/* Task set info */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Informacje o zestawie</h3>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div className="font-medium text-gray-900">{taskSet.name}</div>
{taskSet.description && (
<div className="text-sm text-gray-600 mt-1">{taskSet.description}</div>
)}
<div className="text-sm text-gray-500 mt-2">
Typ projektu: <span className="capitalize">{taskSet.project_type}</span>
</div>
</div>
<div>
<div className="text-sm font-medium text-gray-700 mb-2">
Zawarte szablony zadań ({taskSet.templates?.length || 0}):
</div>
{taskSet.templates && taskSet.templates.length > 0 ? (
<ul className="text-sm text-gray-600 space-y-1">
{taskSet.templates.map((template, index) => (
<li key={template.task_id} className="flex items-center">
<span className="text-gray-400 mr-2">{index + 1}.</span>
{template.name}
{template.max_wait_days > 0 && (
<span className="text-xs text-gray-500 ml-2">
({template.max_wait_days} dni)
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-500">Brak szablonów w zestawie</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Project selection */}
<Card className="mb-6">
<CardHeader>
<h3 className="text-lg font-semibold">Wybierz projekt</h3>
<p className="text-sm text-gray-600">
Wybierz projekt, do którego chcesz zastosować ten zestaw zadań
</p>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekt *
</label>
<select
value={selectedProject}
onChange={(e) => setSelectedProject(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="">Wybierz projekt...</option>
{projects.map((project) => (
<option key={project.project_id} value={project.project_id}>
{project.project_name} - {project.city} ({project.project_type})
</option>
))}
</select>
</div>
{projects.length === 0 && (
<p className="text-gray-500 text-sm">
Brak dostępnych projektów dla tego typu zestawu zadań.
{taskSet.project_type !== 'design+construction' &&
" Spróbuj utworzyć projekt typu 'Projekt + Budowa' lub zmienić typ zestawu."
}
</p>
)}
</div>
</CardContent>
</Card>
{/* Warning */}
<Card className="mb-6 bg-yellow-50 border-yellow-200">
<CardContent className="pt-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Informacja
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
Zastosowanie tego zestawu utworzy {taskSet.templates?.length || 0} nowych zadań w wybranym projekcie.
Zadania będą miały status "Oczekujące" i zostaną przypisane zgodnie z domyślnymi regułami.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isApplying}
>
Anuluj
</Button>
<Button
type="button"
variant="primary"
onClick={handleApply}
disabled={isApplying || !selectedProject}
>
{isApplying ? "Stosowanie..." : "Zastosuj zestaw"}
</Button>
</div>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function EditTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const params = useParams();
const setId = params.id;
const [taskTemplates, setTaskTemplates] = useState([]);
const [taskSet, setTaskSet] = useState(null);
const [formData, setFormData] = useState({
name: "",
description: "",
project_type: "design",
selectedTemplates: []
});
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch task set
const setResponse = await fetch(`/api/task-sets/${setId}`);
if (setResponse.ok) {
const setData = await setResponse.json();
setTaskSet(setData);
setFormData({
name: setData.name,
description: setData.description || "",
project_type: setData.project_type,
selectedTemplates: setData.templates?.map(t => t.task_id) || []
});
} else {
console.error('Failed to fetch task set');
router.push('/task-sets');
return;
}
// Fetch available task templates
const templatesResponse = await fetch('/api/tasks/templates');
if (templatesResponse.ok) {
const templatesData = await templatesResponse.json();
setTaskTemplates(templatesData);
}
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false);
}
};
if (setId) {
fetchData();
}
}, [setId, router]);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
alert("Nazwa zestawu jest wymagana");
return;
}
if (formData.selectedTemplates.length === 0) {
alert("Wybierz przynajmniej jeden szablon zadania");
return;
}
setIsSubmitting(true);
try {
// Update the task set
const updateData = {
name: formData.name.trim(),
description: formData.description.trim(),
task_category: formData.task_category,
templates: formData.selectedTemplates.map((templateId, index) => ({
task_id: templateId,
sort_order: index
}))
};
const response = await fetch(`/api/task-sets/${setId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData)
});
if (!response.ok) {
throw new Error('Failed to update task set');
}
router.push('/task-sets');
} catch (error) {
console.error('Error updating task set:', error);
alert('Wystąpił błąd podczas aktualizacji zestawu zadań');
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async () => {
if (!confirm('Czy na pewno chcesz usunąć ten zestaw zadań?')) {
return;
}
try {
const response = await fetch(`/api/task-sets/${setId}`, {
method: 'DELETE'
});
if (response.ok) {
router.push('/task-sets');
} else {
alert('Wystąpił błąd podczas usuwania zestawu zadań');
}
} catch (error) {
console.error('Error deleting task set:', error);
alert('Wystąpił błąd podczas usuwania zestawu zadań');
}
};
const toggleTemplate = (templateId) => {
setFormData(prev => ({
...prev,
selectedTemplates: prev.selectedTemplates.includes(templateId)
? prev.selectedTemplates.filter(id => id !== templateId)
: [...prev.selectedTemplates, templateId]
}));
};
const moveTemplate = (fromIndex, toIndex) => {
const newSelected = [...formData.selectedTemplates];
const [moved] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, moved);
setFormData(prev => ({
...prev,
selectedTemplates: newSelected
}));
};
if (loading) {
return (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
if (!taskSet) {
return (
<PageContainer>
<PageHeader title="Edycja zestawu zadań" />
<div className="text-center py-12">
<div className="text-gray-500">Zestaw zadań nie został znaleziony</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Edycja zestawu zadań"
description={`Edytuj zestaw: ${taskSet.name}`}
/>
<form onSubmit={handleSubmit} className="max-w-4xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic info */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa zestawu *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="np. Standardowe zadania projektowe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<Input
type="text"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Opcjonalny opis zestawu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategoria zadań *
</label>
<select
value={formData.task_category}
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="design">Zadania projektowe</option>
<option value="construction">Zadania budowlane</option>
</select>
</div>
</CardContent>
</Card>
{/* Template selection */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybrano: {formData.selectedTemplates.length} szablonów
</p>
</CardHeader>
<CardContent>
{formData.selectedTemplates.length > 0 ? (
<div className="space-y-2">
{formData.selectedTemplates.map((templateId, index) => {
const template = taskTemplates.find(t => t.task_id === templateId);
return (
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => index > 0 && moveTemplate(index, index - 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === 0}
>
</button>
<button
type="button"
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === formData.selectedTemplates.length - 1}
>
</button>
<button
type="button"
onClick={() => toggleTemplate(templateId)}
className="text-red-400 hover:text-red-600"
>
×
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
)}
</CardContent>
</Card>
</div>
{/* Available templates */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybierz szablony, które chcesz dodać do zestawu
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{taskTemplates.map((template) => (
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.selectedTemplates.includes(template.task_id)}
onChange={() => toggleTemplate(template.task_id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
{template.description && (
<div className="text-xs text-gray-600">{template.description}</div>
)}
</div>
</label>
))}
</div>
{taskTemplates.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-between mt-6">
<Button
type="button"
variant="danger"
onClick={handleDelete}
disabled={isSubmitting}
>
Usuń zestaw
</Button>
<div className="flex space-x-4">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Zapisywanie..." : "Zapisz zmiany"}
</Button>
</div>
</div>
</form>
</PageContainer>
);
}

View File

@@ -0,0 +1,289 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import PageContainer from "@/components/ui/PageContainer";
import PageHeader from "@/components/ui/PageHeader";
import { useTranslation } from "@/lib/i18n";
export default function NewTaskSetPage() {
const { t } = useTranslation();
const router = useRouter();
const [taskTemplates, setTaskTemplates] = useState([]);
const [formData, setFormData] = useState({
name: "",
description: "",
task_category: "design",
selectedTemplates: []
});
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// Fetch available task templates
const fetchTemplates = async () => {
try {
const response = await fetch('/api/tasks/templates');
if (response.ok) {
const data = await response.json();
setTaskTemplates(data);
}
} catch (error) {
console.error('Error fetching templates:', error);
}
};
fetchTemplates();
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
alert("Nazwa zestawu jest wymagana");
return;
}
if (formData.selectedTemplates.length === 0) {
alert("Wybierz przynajmniej jeden szablon zadania");
return;
}
setIsSubmitting(true);
try {
// Create the task set
const createResponse = await fetch('/api/task-sets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name.trim(),
description: formData.description.trim(),
task_category: formData.task_category
})
});
if (!createResponse.ok) {
throw new Error('Failed to create task set');
}
const { id: setId } = await createResponse.json();
// Add templates to the set
const templatesData = {
templates: formData.selectedTemplates.map((templateId, index) => ({
task_id: templateId,
sort_order: index
}))
};
const updateResponse = await fetch(`/api/task-sets/${setId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templatesData)
});
if (!updateResponse.ok) {
throw new Error('Failed to add templates to task set');
}
router.push('/task-sets');
} catch (error) {
console.error('Error creating task set:', error);
alert('Wystąpił błąd podczas tworzenia zestawu zadań');
} finally {
setIsSubmitting(false);
}
};
const toggleTemplate = (templateId) => {
setFormData(prev => ({
...prev,
selectedTemplates: prev.selectedTemplates.includes(templateId)
? prev.selectedTemplates.filter(id => id !== templateId)
: [...prev.selectedTemplates, templateId]
}));
};
const moveTemplate = (fromIndex, toIndex) => {
const newSelected = [...formData.selectedTemplates];
const [moved] = newSelected.splice(fromIndex, 1);
newSelected.splice(toIndex, 0, moved);
setFormData(prev => ({
...prev,
selectedTemplates: newSelected
}));
};
return (
<PageContainer>
<PageHeader
title="Nowy zestaw zadań"
description="Utwórz nowy zestaw zadań z szablonów"
/>
<form onSubmit={handleSubmit} className="max-w-4xl">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic info */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Informacje podstawowe</h3>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nazwa zestawu *
</label>
<Input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="np. Standardowe zadania projektowe"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Opis
</label>
<Input
type="text"
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Opcjonalny opis zestawu"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kategoria zadań *
</label>
<select
value={formData.task_category}
onChange={(e) => setFormData(prev => ({ ...prev, task_category: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value="design">Zadania projektowe</option>
<option value="construction">Zadania budowlane</option>
</select>
</div>
</CardContent>
</Card>
{/* Template selection */}
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">Wybrane szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybrano: {formData.selectedTemplates.length} szablonów
</p>
</CardHeader>
<CardContent>
{formData.selectedTemplates.length > 0 ? (
<div className="space-y-2">
{formData.selectedTemplates.map((templateId, index) => {
const template = taskTemplates.find(t => t.task_id === templateId);
return (
<div key={templateId} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
<span className="text-sm font-medium text-gray-600">{index + 1}.</span>
<span className="text-sm">{template?.name || 'Nieznany szablon'}</span>
</div>
<div className="flex space-x-1">
<button
type="button"
onClick={() => index > 0 && moveTemplate(index, index - 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === 0}
>
</button>
<button
type="button"
onClick={() => index < formData.selectedTemplates.length - 1 && moveTemplate(index, index + 1)}
className="text-gray-400 hover:text-gray-600"
disabled={index === formData.selectedTemplates.length - 1}
>
</button>
<button
type="button"
onClick={() => toggleTemplate(templateId)}
className="text-red-400 hover:text-red-600"
>
×
</button>
</div>
</div>
);
})}
</div>
) : (
<p className="text-gray-500 text-sm">Brak wybranych szablonów</p>
)}
</CardContent>
</Card>
</div>
{/* Available templates */}
<Card className="mt-6">
<CardHeader>
<h3 className="text-lg font-semibold">Dostępne szablony zadań</h3>
<p className="text-sm text-gray-600">
Wybierz szablony, które chcesz dodać do zestawu
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{taskTemplates.map((template) => (
<label key={template.task_id} className="flex items-center space-x-2 p-2 hover:bg-gray-50 rounded cursor-pointer">
<input
type="checkbox"
checked={formData.selectedTemplates.includes(template.task_id)}
onChange={() => toggleTemplate(template.task_id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<div className="flex-1">
<div className="font-medium text-sm">{template.name}</div>
{template.description && (
<div className="text-xs text-gray-600">{template.description}</div>
)}
</div>
</label>
))}
</div>
{taskTemplates.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">
Brak dostępnych szablonów zadań. Najpierw utwórz szablony w zakładce "Szablony zadań".
</p>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end space-x-4 mt-6">
<Button
type="button"
variant="secondary"
onClick={() => router.back()}
disabled={isSubmitting}
>
Anuluj
</Button>
<Button
type="submit"
variant="primary"
disabled={isSubmitting}
>
{isSubmitting ? "Tworzenie..." : "Utwórz zestaw"}
</Button>
</div>
</form>
</PageContainer>
);
}

189
src/app/task-sets/page.js Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import { useState, useEffect } 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 { useTranslation } from "@/lib/i18n";
export default function TaskSetsPage() {
const { t } = useTranslation();
const [taskSets, setTaskSets] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("all");
useEffect(() => {
const fetchTaskSets = async () => {
try {
const response = await fetch('/api/task-sets');
if (response.ok) {
const data = await response.json();
setTaskSets(data);
} else {
console.error('Failed to fetch task sets');
}
} catch (error) {
console.error('Error fetching task sets:', error);
} finally {
setLoading(false);
}
};
fetchTaskSets();
}, []);
const filteredTaskSets = taskSets.filter(taskSet => {
if (filter === "all") return true;
return taskSet.task_category === filter;
});
const getTaskCategoryBadge = (taskCategory) => {
const colors = {
design: "bg-blue-100 text-blue-800",
construction: "bg-green-100 text-green-800"
};
return (
<Badge className={colors[taskCategory] || "bg-gray-100 text-gray-800"}>
{taskCategory === "design" ? "Zadania projektowe" : taskCategory === "construction" ? "Zadania budowlane" : taskCategory}
</Badge>
);
};
if (loading) {
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
/>
<div className="flex justify-center items-center h-64">
<div className="text-gray-500">Ładowanie...</div>
</div>
</PageContainer>
);
}
return (
<PageContainer>
<PageHeader
title="Zestawy zadań"
description="Zarządzaj zestawami zadań"
action={
<Link href="/task-sets/new">
<Button variant="primary" size="lg">
<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>
Nowy zestaw
</Button>
</Link>
}
/>
{/* Filter buttons */}
<div className="mb-6">
<div className="flex space-x-2">
{["all", "design", "construction"].map(type => (
<Button
key={type}
variant={filter === type ? "primary" : "secondary"}
size="sm"
onClick={() => setFilter(type)}
>
{type === "all" ? "Wszystkie" :
type === "design" ? "Projektowanie" :
"Budowa"}
</Button>
))}
</div>
</div>
{/* Task sets grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTaskSets.map((taskSet) => (
<Card key={taskSet.set_id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-gray-900">
{taskSet.name}
</h3>
{taskSet.description && (
<p className="text-sm text-gray-600 mt-1">
{taskSet.description}
</p>
)}
</div>
{getTaskCategoryBadge(taskSet.task_category)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="text-sm text-gray-600">
<span className="font-medium">Szablony zadań:</span>{" "}
{taskSet.templates?.length || 0}
</div>
{taskSet.templates && taskSet.templates.length > 0 && (
<div className="text-xs text-gray-500">
<ul className="list-disc list-inside space-y-1">
{taskSet.templates.slice(0, 3).map((template) => (
<li key={template.task_id}>{template.name}</li>
))}
{taskSet.templates.length > 3 && (
<li>...i {taskSet.templates.length - 3} więcej</li>
)}
</ul>
</div>
)}
<div className="flex space-x-2 pt-2">
<Link href={`/task-sets/${taskSet.set_id}`}>
<Button variant="secondary" size="sm">
Edytuj
</Button>
</Link>
<Link href={`/task-sets/${taskSet.set_id}/apply`}>
<Button variant="primary" size="sm">
Zastosuj
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{filteredTaskSets.length === 0 && (
<div className="text-center py-12">
<div className="text-gray-500 mb-4">
{filter === "all"
? "Brak zestawów zadań"
: `Brak zestawów zadań dla typu "${filter}"`
}
</div>
<Link href="/task-sets/new">
<Button variant="primary">
Utwórz pierwszy zestaw
</Button>
</Link>
</div>
)}
</PageContainer>
);
}

View File

@@ -8,14 +8,20 @@ import Badge from "@/components/ui/Badge";
import TaskStatusDropdownSimple from "@/components/TaskStatusDropdownSimple"; import TaskStatusDropdownSimple from "@/components/TaskStatusDropdownSimple";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { formatDistanceToNow, parseISO } from "date-fns"; import { formatDistanceToNow, parseISO } from "date-fns";
import { pl, enUS } from "date-fns/locale";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
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 SearchBar from "@/components/ui/SearchBar"; 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 { useTranslation } from "@/lib/i18n";
export default function ProjectTasksPage() { export default function ProjectTasksPage() {
const { t, language } = useTranslation();
// Get locale for date-fns
const locale = language === 'pl' ? pl : enUS;
const [allTasks, setAllTasks] = useState([]); const [allTasks, setAllTasks] = useState([]);
const [filteredTasks, setFilteredTasks] = useState([]); const [filteredTasks, setFilteredTasks] = useState([]);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -148,25 +154,25 @@ export default function ProjectTasksPage() {
const filterOptions = [ const filterOptions = [
{ {
label: "Status", label: t('tasks.status'),
value: statusFilter, value: statusFilter,
onChange: (e) => setStatusFilter(e.target.value), onChange: (e) => setStatusFilter(e.target.value),
options: [ options: [
{ value: "all", label: "All" }, { value: "all", label: t('common.all') },
{ value: "pending", label: "Pending" }, { value: "pending", label: t('taskStatus.pending') },
{ value: "in_progress", label: "In Progress" }, { value: "in_progress", label: t('taskStatus.in_progress') },
{ value: "completed", label: "Completed" }, { value: "completed", label: t('taskStatus.completed') },
], ],
}, },
{ {
label: "Priority", label: t('tasks.priority'),
value: priorityFilter, value: priorityFilter,
onChange: (e) => setPriorityFilter(e.target.value), onChange: (e) => setPriorityFilter(e.target.value),
options: [ options: [
{ value: "all", label: "All" }, { value: "all", label: t('common.all') },
{ value: "high", label: "High" }, { value: "high", label: t('tasks.high') },
{ value: "normal", label: "Normal" }, { value: "normal", label: t('tasks.medium') },
{ value: "low", label: "Low" }, { value: "low", label: t('tasks.low') },
], ],
}, },
]; ];
@@ -174,8 +180,8 @@ export default function ProjectTasksPage() {
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Project Tasks" title={t('tasks.title')}
description="Monitor and manage tasks across all projects" description={t('tasks.subtitle')}
/> />
<SearchBar <SearchBar
searchTerm={searchTerm} searchTerm={searchTerm}
@@ -206,7 +212,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-600">Total Tasks</p> <p className="text-sm font-medium text-gray-600">{t('dashboard.totalTasks')}</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.all} {statusCounts.all}
</p> </p>
@@ -233,7 +239,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-600">Pending</p> <p className="text-sm font-medium text-gray-600">{t('taskStatus.pending')}</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.pending} {statusCounts.pending}
</p> </p>
@@ -260,7 +266,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-600">In Progress</p> <p className="text-sm font-medium text-gray-600">{t('taskStatus.in_progress')}</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.in_progress} {statusCounts.in_progress}
</p> </p>
@@ -287,7 +293,7 @@ export default function ProjectTasksPage() {
</svg> </svg>
</div> </div>
<div className="ml-4"> <div className="ml-4">
<p className="text-sm font-medium text-gray-600">Completed</p> <p className="text-sm font-medium text-gray-600">{t('taskStatus.completed')}</p>
<p className="text-2xl font-bold text-gray-900"> <p className="text-2xl font-bold text-gray-900">
{statusCounts.completed} {statusCounts.completed}
</p> </p>
@@ -379,6 +385,7 @@ export default function ProjectTasksPage() {
Added{" "} Added{" "}
{formatDistanceToNow(parseISO(task.date_added), { {formatDistanceToNow(parseISO(task.date_added), {
addSuffix: true, addSuffix: true,
locale: locale
})} })}
</span> </span>
{task.max_wait_days > 0 && ( {task.max_wait_days > 0 && (

View File

@@ -1,9 +1,6 @@
import db from "@/lib/db"; import db from "@/lib/db";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import EditTaskTemplateClient from "@/components/EditTaskTemplateClient";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import TaskTemplateForm from "@/components/TaskTemplateForm";
export default async function EditTaskTemplatePage({ params }) { export default async function EditTaskTemplatePage({ params }) {
const { id } = await params; const { id } = await params;
@@ -16,52 +13,5 @@ export default async function EditTaskTemplatePage({ params }) {
notFound(); notFound();
} }
return ( return <EditTaskTemplateClient template={template} />;
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-6">
<div className="flex items-center gap-4 mb-8">
<Link href="/tasks/templates">
<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>
Back to Templates
</Button>
</Link>{" "}
<div>
<h1 className="text-3xl font-bold text-gray-900">
Edit Task Template
</h1>
<p className="text-gray-600 mt-1">
Update the details for &ldquo;{template.name}&rdquo;
</p>
</div>
</div>
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
Template Details
</h2>
<p className="text-gray-600">
Modify the template information below
</p>
</CardHeader>
<CardContent>
<TaskTemplateForm templateId={params.id} initialData={template} />
</CardContent>
</Card>
</div>
</div>
);
} }

View File

@@ -1,9 +1,13 @@
"use client";
import TaskTemplateForm from "@/components/TaskTemplateForm"; import TaskTemplateForm from "@/components/TaskTemplateForm";
import { useTranslation } from "@/lib/i18n";
export default function NewTaskTemplatePage() { export default function NewTaskTemplatePage() {
const { t } = useTranslation();
return ( return (
<div className="p-4 max-w-xl mx-auto"> <div className="p-4 max-w-xl mx-auto">
<h1 className="text-xl font-bold mb-4">New Task Template</h1> <h1 className="text-xl font-bold mb-4">{t('taskTemplates.newTemplate')}</h1>
<TaskTemplateForm /> <TaskTemplateForm />
</div> </div>
); );

View File

@@ -1,26 +1,111 @@
import db from "@/lib/db"; "use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
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";
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 { useTranslation } from "@/lib/i18n";
export default function TaskTemplatesPage() { export default function TaskTemplatesPage() {
const templates = db const { t } = useTranslation();
.prepare( const [templates, setTemplates] = useState([]);
` const [loading, setLoading] = useState(true);
SELECT * FROM tasks WHERE is_standard = 1 ORDER BY name ASC
` useEffect(() => {
) const fetchTemplates = async () => {
.all(); try {
const response = await fetch('/api/tasks/templates');
if (response.ok) {
const data = await response.json();
setTemplates(data);
} else {
console.error('Failed to fetch templates');
}
} catch (error) {
console.error('Error fetching templates:', error);
} finally {
setLoading(false);
}
};
fetchTemplates();
}, []);
const getTaskCategoryBadge = (taskCategory) => {
const colors = {
design: "bg-blue-100 text-blue-800",
construction: "bg-green-100 text-green-800"
};
return (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${colors[taskCategory] || "bg-gray-100 text-gray-800"}`}>
{taskCategory === "design" ? "Projektowe" : taskCategory === "construction" ? "Budowlane" : taskCategory}
</span>
);
};
if (loading) {
return (
<PageContainer>
<PageHeader
title={t('taskTemplates.title')}
description={t('taskTemplates.subtitle')}
actions={[
<Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg">
<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>
{t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button>
</Link>
]}
/>
<div className="text-center py-12">
<div className="text-gray-500">{t('common.loading')}</div>
</div>
</PageContainer>
);
}
return ( return (
<PageContainer> <PageContainer>
<PageHeader <PageHeader
title="Task Templates" title={t('taskTemplates.title')}
description="Manage reusable task templates" description={t('taskTemplates.subtitle')}
action={ actions={[
<Link href="/tasks/templates/new"> <Link href="/tasks/templates/new" key="new-template">
<Button variant="primary" size="lg"> <Button variant="primary" size="lg">
<svg <svg
className="w-5 h-5 mr-2" className="w-5 h-5 mr-2"
@@ -35,10 +120,28 @@ export default function TaskTemplatesPage() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
New Template {t('taskTemplates.newTemplate')}
</Button>
</Link>,
<Link href="/task-sets" key="task-sets">
<Button variant="secondary" size="lg">
<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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Zestawy zadań
</Button> </Button>
</Link> </Link>
} ]}
/> />
{templates.length === 0 ? ( {templates.length === 0 ? (
@@ -58,13 +161,13 @@ export default function TaskTemplatesPage() {
</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 task templates yet {t('taskTemplates.noTemplates')}
</h3> </h3>
<p className="text-gray-500 mb-6"> <p className="text-gray-500 mb-6">
Create reusable task templates to streamline your workflow {t('taskTemplates.noTemplatesMessage')}
</p> </p>
<Link href="/tasks/templates/new"> <Link href="/tasks/templates/new">
<Button variant="primary">Create First Template</Button> <Button variant="primary">{t('taskTemplates.newTemplate')}</Button>
</Link> </Link>
</CardContent> </CardContent>
</Card> </Card>
@@ -80,29 +183,24 @@ export default function TaskTemplatesPage() {
<h3 className="text-lg font-semibold text-gray-900 truncate pr-2"> <h3 className="text-lg font-semibold text-gray-900 truncate pr-2">
{template.name} {template.name}
</h3> </h3>
<Badge variant="primary" size="sm"> <div className="flex flex-col gap-1">
{template.max_wait_days} days <Badge variant="primary" size="sm">
</Badge> {template.max_wait_days} {t('common.days')}
</Badge>
{getTaskCategoryBadge(template.task_category)}
</div>
</div> </div>
{template.description && ( {template.description && (
<p className="text-gray-600 text-sm mb-4 line-clamp-2"> <p className="text-gray-600 text-sm mb-4 line-clamp-2">
{template.description} {template.description}
</p> </p>
)}{" "} )}{" "}
<div className="flex items-center justify-between"> <div className="flex items-center justify-end">
<span className="text-xs text-gray-500"> <Link href={`/tasks/templates/${template.task_id}/edit`}>
Template ID: {template.task_id} <Button variant="outline" size="sm">
</span> {t('taskTemplates.editTemplate')}
<div className="flex space-x-2">
<Link href={`/tasks/templates/${template.task_id}/edit`}>
<Button variant="outline" size="sm">
Edit
</Button>
</Link>
<Button variant="secondary" size="sm">
Duplicate
</Button> </Button>
</div> </Link>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -132,7 +132,17 @@ export default function AuditLogViewer() {
const formatTimestamp = (timestamp) => { const formatTimestamp = (timestamp) => {
try { try {
return format(new Date(timestamp), "yyyy-MM-dd HH:mm:ss"); const date = new Date(timestamp);
// Format in Polish timezone
return date.toLocaleString("pl-PL", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch { } catch {
return timestamp; return timestamp;
} }
@@ -280,7 +290,7 @@ export default function AuditLogViewer() {
</div> </div>
{/* Statistics */} {/* Statistics */}
{stats && ( {stats && stats.total > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Total Events</h3> <h3 className="text-lg font-semibold">Total Events</h3>
@@ -289,22 +299,22 @@ export default function AuditLogViewer() {
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Top Action</h3> <h3 className="text-lg font-semibold">Top Action</h3>
<p className="text-sm font-medium"> <p className="text-sm font-medium">
{stats.actionBreakdown[0]?.action || "N/A"} {stats.actionBreakdown && stats.actionBreakdown[0]?.action || "N/A"}
</p> </p>
<p className="text-lg font-bold text-green-600"> <p className="text-lg font-bold text-green-600">
{stats.actionBreakdown[0]?.count || 0} {stats.actionBreakdown && stats.actionBreakdown[0]?.count || 0}
</p> </p>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Active Users</h3> <h3 className="text-lg font-semibold">Active Users</h3>
<p className="text-2xl font-bold text-purple-600"> <p className="text-2xl font-bold text-purple-600">
{stats.userBreakdown.length} {stats.userBreakdown ? stats.userBreakdown.length : 0}
</p> </p>
</div> </div>
<div className="bg-white p-4 rounded-lg shadow"> <div className="bg-white p-4 rounded-lg shadow">
<h3 className="text-lg font-semibold">Resource Types</h3> <h3 className="text-lg font-semibold">Resource Types</h3>
<p className="text-2xl font-bold text-orange-600"> <p className="text-2xl font-bold text-orange-600">
{stats.resourceBreakdown.length} {stats.resourceBreakdown ? stats.resourceBreakdown.length : 0}
</p> </p>
</div> </div>
</div> </div>
@@ -318,43 +328,43 @@ export default function AuditLogViewer() {
)} )}
{/* Audit Logs Table */} {/* Audit Logs Table */}
<div className="bg-white rounded-lg shadow overflow-hidden"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
<thead className="bg-gray-50"> <thead className="bg-gray-50 dark:bg-gray-700">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Timestamp Timestamp
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
User User
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Action Action
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Resource Resource
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
IP Address IP Address
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Details Details
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-600">
{logs.map((log) => ( {logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{formatTimestamp(log.timestamp)} {formatTimestamp(log.timestamp)}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div> <div>
<div className="font-medium"> <div className="font-medium">
{log.user_name || "Anonymous"} {log.user_name || "Anonymous"}
</div> </div>
<div className="text-gray-500">{log.user_email}</div> <div className="text-gray-500 dark:text-gray-400">{log.user_email}</div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm"> <td className="px-6 py-4 whitespace-nowrap text-sm">
@@ -364,26 +374,26 @@ export default function AuditLogViewer() {
{log.action.replace(/_/g, " ").toUpperCase()} {log.action.replace(/_/g, " ").toUpperCase()}
</span> </span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
<div> <div>
<div className="font-medium"> <div className="font-medium">
{log.resource_type || "N/A"} {log.resource_type || "N/A"}
</div> </div>
<div className="text-gray-500"> <div className="text-gray-500 dark:text-gray-400">
ID: {log.resource_id || "N/A"} ID: {log.resource_id || "N/A"}
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{log.ip_address || "Unknown"} {log.ip_address || "Unknown"}
</td> </td>
<td className="px-6 py-4 text-sm text-gray-500"> <td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
{log.details && ( {log.details && (
<details className="cursor-pointer"> <details className="cursor-pointer">
<summary className="text-blue-600 hover:text-blue-800"> <summary className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
View Details View Details
</summary> </summary>
<pre className="mt-2 text-xs bg-gray-100 p-2 rounded overflow-auto max-w-md"> <pre className="mt-2 text-xs bg-gray-100 dark:bg-gray-700 p-2 rounded overflow-auto max-w-md">
{JSON.stringify(log.details, null, 2)} {JSON.stringify(log.details, null, 2)}
</pre> </pre>
</details> </details>
@@ -396,7 +406,7 @@ export default function AuditLogViewer() {
</div> </div>
{logs.length === 0 && !loading && ( {logs.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500 dark:text-gray-400">
No audit logs found matching your criteria. No audit logs found matching your criteria.
</div> </div>
)} )}

View File

@@ -0,0 +1,296 @@
"use client";
import React, { useState } from "react";
import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
export default function ContactForm({ initialData = null, onSave, onCancel }) {
const [form, setForm] = useState({
name: "",
phones: [""],
email: "",
company: "",
position: "",
contact_type: "other",
notes: "",
is_active: true,
...initialData,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
// Handle initial data with phones
React.useEffect(() => {
if (initialData) {
let phones = [""];
if (initialData.phone) {
try {
// Try to parse as JSON array first
const parsed = JSON.parse(initialData.phone);
phones = Array.isArray(parsed) ? parsed : [initialData.phone];
} catch {
// Fall back to comma-separated string
phones = initialData.phone.split(',').map(p => p.trim()).filter(p => p);
}
}
setForm(prev => ({
...prev,
...initialData,
phones: phones.length > 0 ? phones : [""]
}));
}
}, [initialData]);
const isEdit = !!initialData;
function handleChange(e) {
const { name, value, type, checked } = e.target;
setForm((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
}
function handlePhoneChange(index, value) {
setForm(prev => ({
...prev,
phones: prev.phones.map((phone, i) => i === index ? value : phone)
}));
}
function addPhone() {
setForm(prev => ({
...prev,
phones: [...prev.phones, ""]
}));
}
function removePhone(index) {
if (form.phones.length > 1) {
setForm(prev => ({
...prev,
phones: prev.phones.filter((_, i) => i !== index)
}));
}
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setError(null);
try {
// Filter out empty phones and prepare data
const filteredPhones = form.phones.filter(phone => phone.trim());
const submitData = {
...form,
phone: filteredPhones.length > 1 ? JSON.stringify(filteredPhones) : (filteredPhones[0] || null),
phones: undefined // Remove phones array from submission
};
const url = isEdit
? `/api/contacts/${initialData.contact_id}`
: "/api/contacts";
const method = isEdit ? "PUT" : "POST";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(submitData),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to save contact");
}
const contact = await response.json();
onSave?.(contact);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-gray-900">
{isEdit ? "Edytuj kontakt" : "Nowy kontakt"}
</h2>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Imię i nazwisko <span className="text-red-500">*</span>
</label>
<Input
type="text"
name="name"
value={form.name}
onChange={handleChange}
placeholder="Wprowadź imię i nazwisko"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Telefon
</label>
<div className="space-y-2">
{form.phones.map((phone, index) => (
<div key={index} className="flex gap-2">
<Input
type="tel"
value={phone}
onChange={(e) => handlePhoneChange(index, e.target.value)}
placeholder={index === 0 ? "+48 123 456 789" : "Dodatkowy numer"}
className="flex-1"
/>
{form.phones.length > 1 && (
<Button
type="button"
variant="danger"
size="sm"
onClick={() => removePhone(index)}
className="px-2"
>
<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>
))}
<Button
type="button"
variant="secondary"
size="sm"
onClick={addPhone}
className="w-full"
>
<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="M12 4v16m8-8H4" />
</svg>
Dodaj kolejny numer
</Button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<Input
type="email"
name="email"
value={form.email}
onChange={handleChange}
placeholder="email@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Firma
</label>
<Input
type="text"
name="company"
value={form.company}
onChange={handleChange}
placeholder="Nazwa firmy"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Stanowisko
</label>
<Input
type="text"
name="position"
value={form.position}
onChange={handleChange}
placeholder="Kierownik projektu"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Typ kontaktu
</label>
<select
name="contact_type"
value={form.contact_type}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="project">Kontakt projektowy</option>
<option value="contractor">Wykonawca</option>
<option value="office">Urząd</option>
<option value="supplier">Dostawca</option>
<option value="other">Inny</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notatki
</label>
<textarea
name="notes"
value={form.notes}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Dodatkowe informacje..."
/>
</div>
{isEdit && (
<div className="md:col-span-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
name="is_active"
checked={form.is_active}
onChange={handleChange}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">
Kontakt aktywny
</span>
</label>
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4 border-t">
{onCancel && (
<Button type="button" variant="secondary" onClick={onCancel}>
Anuluj
</Button>
)}
<Button type="submit" disabled={loading}>
{loading ? "Zapisywanie..." : isEdit ? "Zapisz zmiany" : "Dodaj kontakt"}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -6,8 +6,10 @@ import { Card, CardHeader, CardContent } from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { formatDateForInput } from "@/lib/utils"; import { formatDateForInput } from "@/lib/utils";
import { useTranslation } from "@/lib/i18n";
export default function ContractForm() { export default function ContractForm() {
const { t } = useTranslation();
const [form, setForm] = useState({ const [form, setForm] = useState({
contract_number: "", contract_number: "",
contract_name: "", contract_name: "",
@@ -42,13 +44,11 @@ export default function ContractForm() {
const contract = await res.json(); const contract = await res.json();
router.push(`/contracts/${contract.contract_id}`); router.push(`/contracts/${contract.contract_id}`);
} else { } else {
alert( alert(t('contracts.failedToCreateContract'));
"Failed to create contract. Please check the data and try again."
);
} }
} catch (error) { } catch (error) {
console.error("Error creating contract:", error); console.error("Error creating contract:", error);
alert("Failed to create contract. Please try again."); alert(t('contracts.failedToCreateContract'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -58,7 +58,7 @@ export default function ContractForm() {
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Contract Details {t('contracts.contractDetails')}
</h2> </h2>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -67,73 +67,73 @@ export default function ContractForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<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">
Contract Number <span className="text-red-500">*</span> {t('contracts.contractNumber')} <span className="text-red-500">*</span>
</label> </label>
<Input <Input
type="text" type="text"
name="contract_number" name="contract_number"
value={form.contract_number || ""} value={form.contract_number || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter contract number" placeholder={t('contracts.enterContractNumber')}
required required
/> />
</div> </div>
<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">
Contract Name {t('contracts.contractName')}
</label> </label>
<Input <Input
type="text" type="text"
name="contract_name" name="contract_name"
value={form.contract_name || ""} value={form.contract_name || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter contract name" placeholder={t('contracts.enterContractName')}
/> />
</div> </div>
<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">
Customer Contract Number {t('contracts.customerContractNumber')}
</label> </label>
<Input <Input
type="text" type="text"
name="customer_contract_number" name="customer_contract_number"
value={form.customer_contract_number || ""} value={form.customer_contract_number || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter customer contract number" placeholder={t('contracts.enterCustomerContractNumber')}
/> />
</div> </div>
<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">
Customer {t('contracts.customer')}
</label> </label>
<Input <Input
type="text" type="text"
name="customer" name="customer"
value={form.customer || ""} value={form.customer || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter customer name" placeholder={t('contracts.enterCustomerName')}
/> />
</div> </div>
<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">
Investor {t('contracts.investor')}
</label> </label>
<Input <Input
type="text" type="text"
name="investor" name="investor"
value={form.investor || ""} value={form.investor || ""}
onChange={handleChange} onChange={handleChange}
placeholder="Enter investor name" placeholder={t('contracts.enterInvestorName')}
/> />
</div> </div>
<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">
Date Signed {t('contracts.dateSigned')}
</label>{" "} </label>{" "}
<Input <Input
type="date" type="date"
@@ -145,7 +145,7 @@ export default function ContractForm() {
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Finish Date {t('contracts.finishDate')}
</label>{" "} </label>{" "}
<Input <Input
type="date" type="date"
@@ -164,7 +164,7 @@ export default function ContractForm() {
onClick={() => router.back()} onClick={() => router.back()}
disabled={loading} disabled={loading}
> >
Cancel {t('common.cancel')}
</Button> </Button>
<Button type="submit" variant="primary" disabled={loading}> <Button type="submit" variant="primary" disabled={loading}>
{loading ? ( {loading ? (
@@ -189,7 +189,7 @@ export default function ContractForm() {
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" 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>
</svg> </svg>
Creating... {t('common.creating')}
</> </>
) : ( ) : (
<> <>
@@ -206,7 +206,7 @@ export default function ContractForm() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Create Contract {t('contracts.createContract')}
</> </>
)} )}
</Button> </Button>

Some files were not shown because too many files have changed in this diff Show More