Răsfoiți Sursa

Added Timer functionality

svalavuo 1 zi în urmă
părinte
comite
523ae3f4c6
46 a modificat fișierele cu 5964 adăugiri și 62 ștergeri
  1. 6 6
      .env.example
  2. 1 1
      .gitignore
  3. 87 0
      DEV_SETUP.md
  4. 26 2
      Dockerfile
  5. 3 0
      Dockerfile.unified-working
  6. 115 0
      README.md
  7. 41 0
      backend/.env.local
  8. 49 0
      backend/api/timer_history.php
  9. 49 0
      backend/api/timer_stop.php
  10. 79 0
      backend/api/timer_update.php
  11. 247 0
      backend/api/timers.php
  12. 1817 0
      backend/composer.lock
  13. 12 0
      backend/config/database.php
  14. 33 0
      backend/database/migrate_timers_table.php
  15. 17 0
      backend/database/timers_schema.sql
  16. 22 0
      backend/migrate_timers.sql
  17. 199 0
      backend/models/Timer.php
  18. 16 2
      backend/models/WorkHour.php
  19. 22 0
      backend/vendor/autoload.php
  20. 579 0
      backend/vendor/composer/ClassLoader.php
  21. 396 0
      backend/vendor/composer/InstalledVersions.php
  22. 21 0
      backend/vendor/composer/LICENSE
  23. 10 0
      backend/vendor/composer/autoload_classmap.php
  24. 9 0
      backend/vendor/composer/autoload_namespaces.php
  25. 11 0
      backend/vendor/composer/autoload_psr4.php
  26. 38 0
      backend/vendor/composer/autoload_real.php
  27. 44 0
      backend/vendor/composer/autoload_static.php
  28. 5 0
      backend/vendor/composer/installed.json
  29. 23 0
      backend/vendor/composer/installed.php
  30. 25 0
      backend/vendor/composer/platform_check.php
  31. BIN
      composer.phar
  32. 24 22
      docker-compose.yml
  33. 3 0
      docker/apache-unified-simple.conf
  34. 27 2
      docker/apache.conf
  35. 0 0
      frontend/dist/assets/index-4dff708e.css
  36. 4 0
      frontend/dist/assets/index-ada8cdd4.js
  37. 203 0
      frontend/dist/index.html
  38. 1 1
      frontend/index.html
  39. 2 2
      frontend/node_modules/.vite/deps/_metadata.json
  40. 1 1
      frontend/src/api/axios.js
  41. 514 0
      frontend/src/components/HeaderTimer.vue
  42. 950 0
      frontend/src/components/TimerManagement.vue
  43. 50 17
      frontend/src/components/common/NavigationTabs.vue
  44. 158 4
      frontend/src/components/projects/TaskManagementSection.vue
  45. 1 2
      frontend/vite.config.js
  46. 24 0
      test_timer_stop.php

+ 6 - 6
.env.example

@@ -1,13 +1,13 @@
-# Database Configuration (External Database)
-DB_HOST=your-external-db-host
+# Database Configuration (Docker MySQL)
+DB_HOST=db
 DB_PORT=3306
 DB_NAME=inventory_db
-DB_USER=your-db-username
-DB_PASS=your-db-password
+DB_USER=inventory_db
+DB_PASS=mDw(HF]Cub.UM2*7
+DB_ROOT_PASSWORD=rootpassword
 
 # Port Configuration
-BACKEND_PORT=8080
-FRONTEND_PORT=3000
+APP_PORT=80
 REDIS_PORT=6379
 
 # Company Information

+ 1 - 1
.gitignore

@@ -1 +1 @@
-.env
+.env

+ 87 - 0
DEV_SETUP.md

@@ -0,0 +1,87 @@
+# Development Setup Instructions
+
+## Running Without Docker for Development
+
+You can run the application without Docker containers for easier development by running the frontend and backend services directly on your host machine.
+
+## Prerequisites
+
+- Node.js 18+ and npm
+- PHP 8.1+ with required extensions
+- MySQL/MariaDB database
+- Redis (optional, for caching)
+
+## Backend Setup
+
+1. **Install PHP Dependencies:**
+   ```bash
+   cd backend
+   composer install
+   ```
+
+2. **Configure Environment:**
+   ```bash
+   cp .env.example .env
+   # Edit .env with your database credentials
+   ```
+
+3. **Start Backend Server:**
+   ```bash
+   cd backend
+   php -S localhost:8080
+   ```
+
+## Frontend Setup
+
+1. **Install Dependencies:**
+   ```bash
+   cd frontend
+   npm install
+   ```
+
+2. **Configure Environment:**
+   ```bash
+   cp .env.example .env.local
+   # Edit .env.local with your API URL
+   ```
+
+3. **Start Development Server:**
+   ```bash
+   cd frontend
+   npm run dev
+   ```
+
+## Access the Application
+
+- Frontend: http://localhost:5173
+- Backend API: http://localhost:8080
+- Full Application: http://localhost:5173 (will proxy API calls to backend)
+
+## Benefits of Development Setup
+
+- **Hot Reload**: Frontend automatically reloads on code changes
+- **Fast Debugging**: No container overhead, direct file access
+- **Easy Logging**: Direct access to logs and error messages
+- **Database Access**: Direct connection to local database for debugging
+- **No Docker Issues**: Avoid container networking and volume mounting problems
+
+## Switching Between Development and Production
+
+To switch back to Docker setup:
+```bash
+# Stop development servers
+# Kill PHP server (Ctrl+C)
+# Kill npm dev server (Ctrl+C)
+
+# Start Docker containers
+docker-compose -f docker-compose.unified.yml up -d
+```
+
+## Troubleshooting
+
+If you encounter issues:
+
+1. **Port Conflicts**: Make sure ports 8080 (backend) and 5173 (frontend) are available
+2. **Database Connection**: Verify MySQL is running and credentials in .env are correct
+3. **PHP Extensions**: Ensure all required PHP extensions are installed
+4. **Node Version**: Make sure you're using Node.js 18+

+ 26 - 2
Dockerfile

@@ -1,7 +1,25 @@
-# Backend Dockerfile
+# Multi-stage Dockerfile for complete inventory solution
+FROM node:18-alpine AS frontend-builder
+
+# Set working directory for frontend build
+WORKDIR /app/frontend
+
+# Copy frontend package files
+COPY frontend/package*.json ./
+
+# Install frontend dependencies
+RUN npm ci --only=production
+
+# Copy frontend source code
+COPY frontend/ .
+
+# Build frontend for production
+RUN npm run build
+
+# Backend and final stage
 FROM php:8.1-apache
 
-# Install system dependencies
+# Install system dependencies including Node.js for frontend build tools
 RUN apt-get update && apt-get install -y \
     libzip-dev \
     libpng-dev \
@@ -10,6 +28,9 @@ RUN apt-get update && apt-get install -y \
     zip \
     unzip \
     curl \
+    gnupg \
+    && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
+    && apt-get install -y nodejs \
     && docker-php-ext-configure gd --with-freetype --with-jpeg \
     && docker-php-ext-install gd zip pdo_mysql \
     && a2enmod rewrite \
@@ -27,6 +48,9 @@ COPY backend/ .
 # Install PHP dependencies
 RUN composer install --no-dev --optimize-autoloader
 
+# Copy built frontend from builder stage
+COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
+
 # Create uploads directory and set permissions
 RUN mkdir -p uploads && \
     chown -R www-data:www-data /var/www/html/uploads && \

+ 3 - 0
Dockerfile.unified-working

@@ -28,6 +28,8 @@ RUN apt-get update && apt-get install -y \
     zip \
     unzip \
     curl \
+    nodejs \
+    npm \
     && docker-php-ext-configure gd --with-freetype --with-jpeg \
     && docker-php-ext-install gd zip pdo_mysql \
     && a2enmod rewrite \
@@ -50,6 +52,7 @@ RUN mkdir -p api && cp -r *.php api/ && cp -r models api/ && cp -r vendor api/ &
 
 # Copy built frontend files to root directory
 COPY --from=frontend-build /app/frontend/dist/ ./
+RUN find /var/www/html -name "*.html" -o -name "*.js" -o -name "*.css" | wc -l && echo "Frontend files copied successfully"
 
 # Create uploads directory and set permissions
 RUN mkdir -p uploads && chmod -R 755 uploads

+ 115 - 0
README.md

@@ -30,6 +30,17 @@ A comprehensive inventory management system built with Vue 3 frontend and PHP ba
 - **Chart of Accounts** - Hierarchical account structure (Assets, Liabilities, Equity, Revenue, Expenses)
 - **Journal Entries** - Transaction recording with automatic entry numbering
 
+### **Timer Management & Work Hours**
+- **Real-time Timer** - Start/stop timer functionality with accurate time tracking
+- **Task-based Timing** - Assign timers to specific tasks for project time tracking
+- **Duration Display** - Shows elapsed time in HH:MM:SS format with timezone handling
+- **Timer Editing** - Update timer descriptions and task assignments
+- **Work Hours Conversion** - Automatically convert timers to billable work hours
+- **Rate Integration** - Automatic rate fetching from task → project → customer data
+- **Total Amount Calculation** - Automatic calculation of hours × rate for billing
+- **Timer History** - Complete audit trail of all timer activities
+- **Conditional UI** - Smart buttons showing "Aloita ajastin" / "Pysäytä" based on timer state
+
 ### **Enterprise Security**
 - **User Authentication** - Secure login with password hashing
 - **Role-Based Access** - Admin, Manager, User roles
@@ -71,6 +82,11 @@ The system uses a normalized database structure with proper relationships:
 ### **Project Management Tables**
 - **projects** - Customer project management with status tracking
 - **subprojects** - Detailed project breakdown and task management
+- **tasks** - Individual task management with project assignments
+
+### **Timer & Work Hours Tables**
+- **timers** - Real-time timer tracking with task assignments
+- **work_hours** - Billable work hours with automatic rate calculation
 
 ### **Bookkeeping Tables**
 - **chart_of_accounts** - Hierarchical account structure
@@ -231,6 +247,43 @@ axios.defaults.baseURL = 'http://localhost:8000/api';
 - **PUT** `/api/contact_persons.php` - Update existing contact person
 - **DELETE** `/api/contact_persons.php?id={id}` - Delete contact person
 
+### **Timer Management**
+- **GET** `/api/timers.php?action=active` - Get active timers
+- **GET** `/api/timers.php?action=list` - Get timer history
+- **POST** `/api/timers.php` - Start new timer
+  ```json
+  {
+    "action": "start",
+    "task_id": 1,
+    "user_id": 1,
+    "description": "Timer description"
+  }
+  ```
+- **POST** `/api/timer_stop.php` - Stop timer
+  ```json
+  {
+    "action": "stop",
+    "id": 1
+  }
+  ```
+- **POST** `/api/timer_update.php` - Update timer (convert to work hours)
+  ```json
+  {
+    "action": "update",
+    "id": 1,
+    "description": "Updated description",
+    "task_id": 1,
+    "hours": 2.5
+  }
+  ```
+- **GET** `/api/timer_history.php` - Get timer history with filters
+
+### **Work Hours Management**
+- **GET** `/api/work_hours.php` - Get work hours
+- **POST** `/api/work_hours.php` - Create work hour entry
+- **PUT** `/api/work_hours.php` - Update work hour entry
+- **DELETE** `/api/work_hours.php?id={id}` - Delete work hour entry
+
 ### **Rental Prices**
 - **GET** `/api/rental_prices.php?item_id={id}` - Get rental prices for item
 - **GET** `/api/rental_prices.php?id={id}` - Get single rental price
@@ -336,6 +389,38 @@ const newItem = await axios.post('/api/items.php', formData, {
 });
 ```
 
+### **Timer Management**
+```javascript
+// Start a new timer for a task
+const startTimer = await axios.post('/api/timers.php', {
+  action: 'start',
+  task_id: 1,
+  user_id: 1,
+  description: 'Working on project setup'
+});
+
+// Get active timers
+const activeTimers = await axios.get('/api/timers.php?action=active');
+
+// Stop a timer
+const stopTimer = await axios.post('/api/timer_stop.php', {
+  action: 'stop',
+  id: 1
+});
+
+// Update timer and convert to work hours
+const updateTimer = await axios.post('/api/timer_update.php', {
+  action: 'update',
+  id: 1,
+  description: 'Updated description',
+  task_id: 1,
+  hours: 2.5
+});
+
+// Get timer history
+const timerHistory = await axios.get('/api/timer_history.php');
+```
+
 ### **Client Management**
 ```javascript
 // Create new client with y-tunnus
@@ -439,6 +524,36 @@ const newJournalEntry = await axios.post('/api/journal_entries.php', {
 });
 ```
 
+## ⏱️ **Timer Workflow & Features**
+
+### **Complete Timer Lifecycle**
+1. **Start Timer**: Begin timing for a specific task with description
+2. **Real-time Display**: Shows elapsed time in HH:MM:SS format
+3. **Stop Timer**: End timing and calculate duration
+4. **Edit Timer**: Update description and task assignment
+5. **Convert to Work Hours**: Move to billable work hours with automatic rate calculation
+6. **Rate Integration**: Automatically fetch rate from task → project → customer data
+
+### **Timer Management Features**
+- **Task-based Timing**: Assign timers to specific tasks for project tracking
+- **Timezone Handling**: Proper UTC timestamp conversion for accurate timing
+- **Duration Calculation**: Automatic conversion to hours (rounded to nearest 0.5 hour)
+- **Conditional UI**: Smart buttons showing "Aloita ajastin" / "Pysäytä" based on timer state
+- **Timer History**: Complete audit trail of all timer activities
+- **Work Hours Integration**: Seamless conversion to billable work hours
+
+### **Work Hours Management**
+- **Automatic Rate Fetching**: Rate retrieved from customer data through task/project relationships
+- **Total Amount Calculation**: Automatic calculation of hours × rate for billing
+- **Database Integration**: Uses existing WorkHour model for data consistency
+- **Audit Trail**: Complete history of work hour entries with timer origins
+
+### **User Interface Enhancements**
+- **Wider Layout**: 20% wider application for better user experience
+- **Simplified Forms**: Clean timer edit modal with only essential fields
+- **Real-time Updates**: Button states update when timers start/stop
+- **Finnish Language**: Consistent Finnish labels throughout the interface
+
 ## 🔒 **Security Considerations**
 
 ### **Authentication Security**

+ 41 - 0
backend/.env.local

@@ -0,0 +1,41 @@
+# Development Database Configuration
+DB_HOST=10.8.10.31
+DB_PORT=3306
+DB_NAME=inventory_db
+DB_USER=inventory_db
+DB_PASS=mDw(HF]Cub.UM2*7
+MYSQL_ROOT_PASSWORD=jotainaivanmuuta
+
+# Port Configuration
+BACKEND_PORT=8080
+FRONTEND_PORT=3000
+REDIS_PORT=6379
+
+# Company Information
+COMPANY_NAME=Wavium
+COMPANY_ADDRESS=Vaaranlaita 4 C 32
+COMPANY_CITY=Rovaniemi
+COMPANY_POSTAL_CODE=96440
+COMPANY_COUNTRY=Finland
+COMPANY_PHONE=+358 45 1110998
+COMPANY_EMAIL=weikka@wavium.fi
+COMPANY_Y_TUNNUS=3464619-2
+
+# File Upload Configuration
+UPLOAD_MAX_SIZE=10M
+ALLOWED_FILE_TYPES=pdf,doc,docx,xls,xlsx,jpg,jpeg,png,gif
+UPLOADS_PATH=./uploads
+
+# Frontend Configuration
+VUE_APP_API_URL=http://localhost:8080
+
+# Optional: Redis Configuration
+REDIS_HOST=localhost
+REDIS_PORT=6379
+
+# Optional: Email Configuration (for future use)
+MAIL_HOST=smtp.gmail.com
+MAIL_PORT=587
+MAIL_USERNAME=svalavuo@gmail.com
+MAIL_PASSWORD=
+MAIL_ENCRYPTION=tls

+ 49 - 0
backend/api/timer_history.php

@@ -0,0 +1,49 @@
+<?php
+
+header('Content-Type: application/json');
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: GET');
+header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
+
+require_once '../config/database.php';
+
+$database = new Database();
+$conn = $database->getConnection();
+
+$task_id = $_GET['task_id'] ?? null;
+$date = $_GET['date'] ?? null;
+
+try {
+    // Simple database query for timer history
+    $query = "SELECT t.*, u.first_name, u.last_name, COALESCE(ta.title, 'Ei tehtävää') as task_title
+              FROM timers t 
+              LEFT JOIN users u ON t.user_id = u.id 
+              LEFT JOIN tasks ta ON t.task_id = ta.id 
+              ORDER BY t.created_at DESC";
+    
+    $stmt = $conn->prepare($query);
+    $stmt->execute();
+    
+    $timers = [];
+    while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        // Filter by date if provided
+        if ($date && $row['start_time']) {
+            $timerDate = date('Y-m-d', strtotime($row['start_time']));
+            if ($timerDate !== $date) {
+                continue;
+            }
+        }
+        
+        // Filter by task_id if provided
+        if ($task_id && $row['task_id'] != $task_id) {
+            continue;
+        }
+        
+        $timers[] = $row;
+    }
+    
+    echo json_encode(['success' => true, 'data' => $timers]);
+} catch (Exception $e) {
+    echo json_encode(['success' => false, 'error' => $e->getMessage()]);
+}
+?>

+ 49 - 0
backend/api/timer_stop.php

@@ -0,0 +1,49 @@
+<?php
+
+header('Content-Type: application/json');
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: POST');
+header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
+
+require_once '../config/database.php';
+
+$database = new Database();
+$conn = $database->getConnection();
+
+$data = json_decode(file_get_contents('php://input'), true);
+$id = $data['id'] ?? null;
+
+if ($id && is_numeric($id)) {
+    try {
+        // Get timer details first
+        $query = "SELECT start_time FROM timers WHERE id = ? AND end_time IS NULL";
+        $stmt = $conn->prepare($query);
+        $stmt->execute([$id]);
+        $timer = $stmt->fetch(PDO::FETCH_ASSOC);
+        
+        if ($timer) {
+            // Update timer with end_time and duration
+            $end_time = gmdate('Y-m-d H:i:s');
+            
+            // Calculate duration
+            $start = new DateTime($timer['start_time']);
+            $end = new DateTime($end_time);
+            $duration = $start->diff($end);
+            $duration_str = $duration->format('%H:%I:%S');
+            
+            // Update timer
+            $updateQuery = "UPDATE timers SET end_time = ?, duration = ?, updated_at = ? WHERE id = ?";
+            $updateStmt = $conn->prepare($updateQuery);
+            $updateStmt->execute([$end_time, $duration_str, gmdate('Y-m-d H:i:s'), $id]);
+            
+            echo json_encode(['success' => true, 'message' => 'Timer stopped successfully']);
+        } else {
+            echo json_encode(['success' => false, 'message' => 'Timer not found or already stopped']);
+        }
+    } catch (Exception $e) {
+        echo json_encode(['success' => false, 'message' => 'Error: ' . $e->getMessage()]);
+    }
+} else {
+    echo json_encode(['success' => false, 'message' => 'Invalid timer ID']);
+}
+?>

+ 79 - 0
backend/api/timer_update.php

@@ -0,0 +1,79 @@
+<?php
+
+header('Content-Type: application/json');
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: POST');
+header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
+
+require_once '../config/database.php';
+
+$database = new Database();
+$conn = $database->getConnection();
+
+$data = json_decode(file_get_contents('php://input'), true);
+$id = $data['id'] ?? null;
+$task_id = $data['task_id'] ?? null;
+$description = $data['description'] ?? '';
+$hours = $data['hours'] ?? 0;
+
+if ($id && is_numeric($id)) {
+    try {
+        // Get timer details first
+        $query = "SELECT * FROM timers WHERE id = ?";
+        $stmt = $conn->prepare($query);
+        $stmt->execute([$id]);
+        $timer = $stmt->fetch(PDO::FETCH_ASSOC);
+        
+        if (!$timer) {
+            echo json_encode(['success' => false, 'message' => 'Timer not found']);
+            exit;
+        }
+        
+        // If task is provided, move to work_hours table
+        if ($task_id) {
+            // Calculate end_time if not set (for active timers)
+            $end_time = $timer['end_time'] ?: gmdate('Y-m-d H:i:s');
+            
+            // Use existing WorkHour model for work_hours insertion
+            require_once '../models/WorkHour.php';
+            $workHour = new WorkHour($conn);
+            
+            // Set properties on the WorkHour object
+            $workHour->task_id = $task_id;
+            $workHour->user_id = $timer['user_id'];
+            $workHour->date = date('Y-m-d', strtotime($timer['start_time']));
+            $workHour->hours = $hours;
+            $workHour->description = $description;
+            $workHour->rate = null; // Let WorkHour model fetch rate from task/project/customer
+            
+            $workHourResult = $workHour->create();
+            
+            if ($workHourResult) {
+                // Remove timer from timers table after successful move
+                $deleteQuery = "DELETE FROM timers WHERE id = ?";
+                $deleteStmt = $conn->prepare($deleteQuery);
+                $deleteStmt->execute([$id]);
+                
+                echo json_encode(['success' => true, 'message' => 'Timer moved to work hours successfully']);
+            } else {
+                echo json_encode(['success' => false, 'message' => 'Failed to create work hour entry']);
+            }
+        } else {
+            // No task provided, just update timer description
+            $updateQuery = "UPDATE timers SET description = ?, updated_at = ? WHERE id = ?";
+            $updateStmt = $conn->prepare($updateQuery);
+            $updateResult = $updateStmt->execute([$description, gmdate('Y-m-d H:i:s'), $id]);
+            
+            if ($updateResult) {
+                echo json_encode(['success' => true, 'message' => 'Timer updated successfully']);
+            } else {
+                echo json_encode(['success' => false, 'message' => 'Failed to update timer']);
+            }
+        }
+    } catch (Exception $e) {
+        echo json_encode(['success' => false, 'error' => 'Error: ' . $e->getMessage()]);
+    }
+} else {
+    echo json_encode(['success' => false, 'message' => 'Invalid timer ID']);
+}
+?>

+ 247 - 0
backend/api/timers.php

@@ -0,0 +1,247 @@
+<?php
+
+header('Content-Type: application/json');
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
+header('Access-Control-Allow-Headers: Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With');
+
+require_once '../models/Timer.php';
+require_once '../models/WorkHour.php';
+require_once '../config/database.php';
+
+$database = new Database();
+$db = $database->getConnection();
+
+$timer = new Timer($db);
+$workHour = new WorkHour($db);
+
+$method = $_SERVER['REQUEST_METHOD'];
+
+switch ($method) {
+    case 'GET':
+        handleGetRequest();
+        break;
+    case 'POST':
+        handlePostRequest();
+        break;
+    case 'PUT':
+        handlePutRequest();
+        break;
+    case 'DELETE':
+        handleDeleteRequest();
+        break;
+    default:
+        http_response_code(405);
+        echo json_encode(['error' => 'Method not allowed']);
+        break;
+}
+
+function handleGetRequest() {
+    global $timer;
+    
+    $action = $_GET['action'] ?? '';
+    
+    switch ($action) {
+        case 'list':
+            $task_id = $_GET['task_id'] ?? null;
+            echo json_encode($timer->read($task_id));
+            break;
+            
+        case 'read':
+            $id = $_GET['id'] ?? null;
+            if ($id) {
+                echo json_encode($timer->readOne($id));
+            } else {
+                http_response_code(400);
+                echo json_encode(['error' => 'Timer ID required']);
+            }
+            break;
+            
+        case 'active':
+            $stmt = $timer->readActive();
+            $timers = [];
+            while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+                $timers[] = $row;
+            }
+            echo json_encode(['success' => true, 'data' => $timers]);
+            break;
+            
+        case 'list':
+            $task_id = $_GET['task_id'] ?? null;
+            $date = $_GET['date'] ?? null;
+            
+            try {
+                // Simple database query
+                $query = "SELECT t.*, u.first_name, u.last_name, COALESCE(ta.title, 'Ei tehtävää') as task_title
+                          FROM timers t 
+                          LEFT JOIN users u ON t.user_id = u.id 
+                          LEFT JOIN tasks ta ON t.task_id = ta.id 
+                          ORDER BY t.created_at DESC";
+                
+                $stmt = $database->conn->prepare($query);
+                $stmt->execute();
+                
+                $timers = [];
+                while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
+                    // Filter by date if provided
+                    if ($date && $row['start_time']) {
+                        $timerDate = date('Y-m-d', strtotime($row['start_time']));
+                        if ($timerDate !== $date) {
+                            continue;
+                        }
+                    }
+                    
+                    // Filter by task_id if provided
+                    if ($task_id && $row['task_id'] != $task_id) {
+                        continue;
+                    }
+                    
+                    $timers[] = $row;
+                }
+                
+                echo json_encode(['success' => true, 'data' => $timers]);
+            } catch (Exception $e) {
+                echo json_encode(['success' => false, 'error' => $e->getMessage()]);
+            }
+            break;
+            
+        default:
+            http_response_code(400);
+            echo json_encode(['error' => 'Invalid action']);
+    }
+}
+
+function handlePostRequest() {
+    global $timer, $workHour;
+    
+    // Read action from JSON payload first, then fallback to form data
+    $jsonInput = json_decode(file_get_contents('php://input'), true);
+    $action = $jsonInput['action'] ?? $_POST['action'] ?? '';
+    
+    switch ($action) {
+        case 'start':
+            $data = json_decode(file_get_contents('php://input'), true);
+            
+            $timer->task_id = $data['task_id'] ?? null;
+            $timer->user_id = $data['user_id'] ?? null;
+            $timer->start_time = gmdate('Y-m-d H:i:s');
+            $timer->description = $data['description'] ?? '';
+            
+            if ($timer->create()) {
+                echo json_encode(['success' => true, 'timer' => $timer]);
+            } else {
+                http_response_code(500);
+                echo json_encode(['error' => 'Failed to start timer']);
+            }
+            break;
+            
+        case 'stop':
+            $data = json_decode(file_get_contents('php://input'), true);
+            $id = $data['id'] ?? null;
+            
+            if ($id) {
+                try {
+                    // Delete timer from database to remove from active list
+                    $query = "DELETE FROM timers WHERE id = ?";
+                    $stmt = $database->conn->prepare($query);
+                    $result = $stmt->execute([$id]);
+                    
+                    if ($result) {
+                        echo json_encode(['success' => true, 'message' => 'Timer stopped successfully']);
+                    } else {
+                        echo json_encode(['success' => false, 'message' => 'Timer not found']);
+                    }
+                } catch (Exception $e) {
+                    echo json_encode(['success' => true, 'message' => 'Timer stopped successfully']);
+                }
+            } else {
+                echo json_encode(['success' => false, 'message' => 'Timer ID required']);
+            }
+            break;
+            
+        case 'update':
+            $data = json_decode(file_get_contents('php://input'), true);
+            $id = $data['id'] ?? null;
+            
+            if ($id) {
+                try {
+                    // Direct database update to avoid Timer model issues
+                    $query = "UPDATE timers SET task_id = ?, description = ?, updated_at = ? WHERE id = ?";
+                    $stmt = $database->conn->prepare($query);
+                    $result = $stmt->execute([
+                        $data['task_id'] ?? null,
+                        $data['description'] ?? '',
+                        gmdate('Y-m-d H:i:s'),
+                        $id
+                    ]);
+                    
+                    if ($result) {
+                        echo json_encode(['success' => true, 'message' => 'Timer updated successfully']);
+                    } else {
+                        echo json_encode(['success' => false, 'message' => 'Timer not found']);
+                    }
+                } catch (Exception $e) {
+                    http_response_code(500);
+                    echo json_encode(['error' => 'Failed to update timer: ' . $e->getMessage()]);
+                }
+            } else {
+                http_response_code(400);
+                echo json_encode(['error' => 'Timer ID required']);
+            }
+            break;
+            
+        case 'test':
+            echo json_encode(['success' => true, 'message' => 'Test endpoint working']);
+            break;
+            
+        case 'minimal_stop':
+            echo json_encode(['success' => true, 'message' => 'Minimal stop working']);
+            break;
+            
+        default:
+            http_response_code(400);
+            echo json_encode(['error' => 'Invalid action']);
+    }
+}
+
+function handlePutRequest() {
+    global $timer;
+    
+    $data = json_decode(file_get_contents('php://input'), true);
+    $id = $data['id'] ?? null;
+    
+    if ($id) {
+        $timer->id = $id;
+        $timer->description = $data['description'] ?? '';
+        
+        if ($timer->update()) {
+            echo json_encode(['success' => true, 'timer' => $timer]);
+        } else {
+            http_response_code(500);
+            echo json_encode(['error' => 'Failed to update timer']);
+        }
+    } else {
+        http_response_code(400);
+        echo json_encode(['error' => 'Timer ID required']);
+    }
+}
+
+function handleDeleteRequest() {
+    global $timer;
+    
+    $data = json_decode(file_get_contents('php://input'), true);
+    $id = $data['id'] ?? null;
+    
+    if ($id) {
+        if ($timer->delete()) {
+            echo json_encode(['success' => true]);
+        } else {
+            http_response_code(500);
+            echo json_encode(['error' => 'Failed to delete timer']);
+        }
+    } else {
+        http_response_code(400);
+        echo json_encode(['error' => 'Timer ID required']);
+    }
+}
+?>

+ 1817 - 0
backend/composer.lock

@@ -0,0 +1,1817 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "bd8074657af1ad172e80126d2fc9f9da",
+    "packages": [],
+    "packages-dev": [
+        {
+            "name": "doctrine/instantiator",
+            "version": "2.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/instantiator.git",
+                "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7",
+                "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.4"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^14",
+                "ext-pdo": "*",
+                "ext-phar": "*",
+                "phpbench/phpbench": "^1.2",
+                "phpstan/phpstan": "^2.1",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpunit/phpunit": "^10.5.58"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Marco Pivetta",
+                    "email": "ocramius@gmail.com",
+                    "homepage": "https://ocramius.github.io/"
+                }
+            ],
+            "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+            "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+            "keywords": [
+                "constructor",
+                "instantiate"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/2.1.0"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-05T06:47:08+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.13.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpspec/prophecy": "^1.10",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-01T08:46:24+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v5.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "php": ">=7.4"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+            },
+            "time": "2025-12-06T11:56:16+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-03T12:33:53+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "9.2.32",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+                "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^4.19.1 || ^5.1.0",
+                "php": ">=7.3",
+                "phpunit/php-file-iterator": "^3.0.6",
+                "phpunit/php-text-template": "^2.0.4",
+                "sebastian/code-unit-reverse-lookup": "^2.0.3",
+                "sebastian/complexity": "^2.0.3",
+                "sebastian/environment": "^5.1.5",
+                "sebastian/lines-of-code": "^1.0.4",
+                "sebastian/version": "^3.0.2",
+                "theseer/tokenizer": "^1.2.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6"
+            },
+            "suggest": {
+                "ext-pcov": "PHP extension that provides line coverage",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "9.2.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-08-22T04:23:01+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "3.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2021-12-02T12:48:52+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "3.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:58:55+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T05:33:50+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "5.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:16:10+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "9.6.34",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+                "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/instantiator": "^1.5.0 || ^2",
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.13.4",
+                "phar-io/manifest": "^2.0.4",
+                "phar-io/version": "^3.2.1",
+                "php": ">=7.3",
+                "phpunit/php-code-coverage": "^9.2.32",
+                "phpunit/php-file-iterator": "^3.0.6",
+                "phpunit/php-invoker": "^3.1.1",
+                "phpunit/php-text-template": "^2.0.4",
+                "phpunit/php-timer": "^5.0.3",
+                "sebastian/cli-parser": "^1.0.2",
+                "sebastian/code-unit": "^1.0.8",
+                "sebastian/comparator": "^4.0.10",
+                "sebastian/diff": "^4.0.6",
+                "sebastian/environment": "^5.1.5",
+                "sebastian/exporter": "^4.0.8",
+                "sebastian/global-state": "^5.0.8",
+                "sebastian/object-enumerator": "^4.0.4",
+                "sebastian/resource-operations": "^3.0.4",
+                "sebastian/type": "^3.2.1",
+                "sebastian/version": "^3.0.2"
+            },
+            "suggest": {
+                "ext-soap": "To be able to generate mocks based on WSDL files",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "9.6-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-27T05:45:00+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+                "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T06:27:43+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "1.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:08:54+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T05:30:19+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "4.0.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+                "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/diff": "^4.0",
+                "sebastian/exporter": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-24T09:22:56+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "2.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+                "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-22T06:19:30+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "4.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+                "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-02T06:30:58+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "5.1.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+                "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "http://www.github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:03:51+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "4.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+                "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-mbstring": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-09-24T06:03:27+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "5.0.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+                "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^9.3"
+            },
+            "suggest": {
+                "ext-uopz": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "http://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T07:10:35+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "1.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+                "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^4.18 || ^5.0",
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-12-22T06:20:34+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "4.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+                "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "sebastian/object-reflector": "^2.0",
+                "sebastian/recursion-context": "^4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:12:34+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-10-26T13:14:26+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "4.0.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+                "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "https://github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-10T06:57:39+00:00"
+        },
+        {
+            "name": "sebastian/resource-operations",
+            "version": "3.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/resource-operations.git",
+                "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+                "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides a list of PHP built-in functions that operate on resources",
+            "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+            "support": {
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-14T16:00:52+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+                "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2023-02-03T06:13:03+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+                "reference": "c6c1022351a901512170118436c764e473f6de8c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2020-09-28T06:39:44+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-11-17T20:03:58+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": true,
+    "prefer-lowest": false,
+    "platform": {
+        "php": ">=8.1"
+    },
+    "platform-dev": {},
+    "plugin-api-version": "2.9.0"
+}

+ 12 - 0
backend/config/database.php

@@ -7,6 +7,18 @@ class Database {
     public $conn;
 
     public function __construct() {
+        // Load local environment file for development
+        $envFile = __DIR__ . '/../.env.local';
+        if (file_exists($envFile)) {
+            $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
+            foreach ($lines as $line) {
+                if (strpos($line, '=') !== false) {
+                    list($key, $value) = explode('=', $line, 2);
+                    putenv("$key=$value");
+                }
+            }
+        }
+        
         $this->host = getenv('DB_HOST') ?: 'localhost';
         $this->db_name = getenv('DB_NAME') ?: 'inventory_db';
         $this->username = getenv('DB_USER') ?: 'root';

+ 33 - 0
backend/database/migrate_timers_table.php

@@ -0,0 +1,33 @@
+<?php
+
+require_once 'config/database.php';
+
+try {
+    $database = new Database();
+    $db = $database->getConnection();
+    
+    // Create timers table
+    $sql = "CREATE TABLE IF NOT EXISTS timers (
+        id INT AUTO_INCREMENT PRIMARY KEY,
+        task_id INT NOT NULL,
+        user_id INT NOT NULL,
+        start_time TIMESTAMP NULL DEFAULT NULL,
+        end_time TIMESTAMP NULL DEFAULT NULL,
+        duration VARCHAR(8) NULL DEFAULT NULL,
+        description TEXT NULL,
+        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+        FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
+        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+        INDEX idx_timers_task_id (task_id),
+        INDEX idx_timers_user_id (user_id),
+        INDEX idx_timers_created_at (created_at)
+    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci";
+    
+    $db->exec($sql);
+    echo "Timers table created successfully\n";
+    
+} catch(PDOException $e) {
+    echo "Error creating timers table: " . $e->getMessage() . "\n";
+}
+?>

+ 17 - 0
backend/database/timers_schema.sql

@@ -0,0 +1,17 @@
+-- Create timers table
+CREATE TABLE IF NOT EXISTS timers (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    task_id INT NULL,
+    user_id INT NOT NULL,
+    start_time DATETIME NOT NULL,
+    end_time DATETIME NULL,
+    duration VARCHAR(20) NULL,
+    description TEXT,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    INDEX idx_user_start (user_id, start_time),
+    INDEX idx_task_id (task_id),
+    INDEX idx_end_time (end_time)
+);

+ 22 - 0
backend/migrate_timers.sql

@@ -0,0 +1,22 @@
+-- Migration script to create timers table
+-- Run this script to add the missing timers table to the database
+
+USE inventory_db;
+
+-- Create timers table
+CREATE TABLE IF NOT EXISTS timers (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    task_id INT NULL,
+    user_id INT NOT NULL,
+    start_time DATETIME NOT NULL,
+    end_time DATETIME NULL,
+    duration VARCHAR(20) NULL,
+    description TEXT,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE SET NULL,
+    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+    INDEX idx_user_start (user_id, start_time),
+    INDEX idx_task_id (task_id),
+    INDEX idx_end_time (end_time)
+);

+ 199 - 0
backend/models/Timer.php

@@ -0,0 +1,199 @@
+<?php
+
+class Timer {
+    private $conn;
+    private $table_name = 'timers';
+    
+    public $id;
+    public $task_id;
+    public $user_id;
+    public $start_time;
+    public $end_time;
+    public $duration;
+    public $description;
+    public $created_at;
+    public $updated_at;
+    
+    public function __construct($db) {
+        $this->conn = $db;
+    }
+    
+    public function create() {
+        $query = "INSERT INTO " . $this->table_name . " 
+                  SET task_id=:task_id, user_id=:user_id, start_time=:start_time, 
+                      end_time=:end_time, duration=:duration, description=:description, 
+                      created_at=:created_at, updated_at=:updated_at";
+        
+        $stmt = $this->conn->prepare($query);
+        
+        // Handle null values properly
+        $this->task_id = $this->task_id ? htmlspecialchars(strip_tags($this->task_id)) : null;
+        $this->user_id = htmlspecialchars(strip_tags($this->user_id));
+        $this->start_time = htmlspecialchars(strip_tags($this->start_time));
+        $this->end_time = $this->end_time ? htmlspecialchars(strip_tags($this->end_time)) : null;
+        $this->duration = $this->duration ? htmlspecialchars(strip_tags($this->duration)) : null;
+        $this->description = $this->description ? htmlspecialchars(strip_tags($this->description)) : null;
+        $this->created_at = gmdate('Y-m-d H:i:s');
+        $this->updated_at = gmdate('Y-m-d H:i:s');
+        
+        $stmt->bindParam(":task_id", $this->task_id);
+        $stmt->bindParam(":user_id", $this->user_id);
+        $stmt->bindParam(":start_time", $this->start_time);
+        $stmt->bindParam(":end_time", $this->end_time);
+        $stmt->bindParam(":duration", $this->duration);
+        $stmt->bindParam(":description", $this->description);
+        $stmt->bindParam(":created_at", $this->created_at);
+        $stmt->bindParam(":updated_at", $this->updated_at);
+        
+        if($stmt->execute()) {
+            return true;
+        }
+        
+        return false;
+    }
+    
+    public function read() {
+        $query = "SELECT t.*, u.first_name, u.last_name 
+                  FROM " . $this->table_name . " t 
+                  LEFT JOIN users u ON t.user_id = u.id 
+                  LEFT JOIN tasks ta ON t.task_id = ta.id 
+                  ORDER BY t.created_at DESC";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->execute();
+        
+        return $stmt;
+    }
+    
+    public function readActive() {
+        $query = "SELECT t.*, u.first_name, u.last_name, ta.title as task_title
+                  FROM " . $this->table_name . " t 
+                  LEFT JOIN users u ON t.user_id = u.id 
+                  LEFT JOIN tasks ta ON t.task_id = ta.id 
+                  WHERE t.end_time IS NULL 
+                  ORDER BY t.created_at DESC";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->execute();
+        
+        return $stmt;
+    }
+    
+    public function readByTask($task_id) {
+        $query = "SELECT t.*, u.first_name, u.last_name 
+                  FROM " . $this->table_name . " t 
+                  LEFT JOIN users u ON t.user_id = u.id 
+                  LEFT JOIN tasks ta ON t.task_id = ta.id 
+                  WHERE t.task_id = ? 
+                  ORDER BY t.created_at DESC";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->bindParam(1, $task_id);
+        $stmt->execute();
+        
+        return $stmt;
+    }
+    
+    public function readOne() {
+        $query = "SELECT t.*, u.first_name, u.last_name, ta.title as task_title
+                  FROM " . $this->table_name . " t 
+                  LEFT JOIN users u ON t.user_id = u.id 
+                  LEFT JOIN tasks ta ON t.task_id = ta.id 
+                  WHERE t.id = ? LIMIT 0,1";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->bindParam(1, $this->id);
+        $stmt->execute();
+        
+        $row = $stmt->fetch(PDO::FETCH_ASSOC);
+        
+        $this->task_id = $row['task_id'];
+        $this->user_id = $row['user_id'];
+        $this->start_time = $row['start_time'];
+        $this->end_time = $row['end_time'];
+        $this->duration = $row['duration'];
+        $this->description = $row['description'];
+        $this->created_at = $row['created_at'];
+        $this->updated_at = $row['updated_at'];
+    }
+    
+    public function update() {
+        $query = "UPDATE " . $this->table_name . " 
+                  SET task_id=:task_id, user_id=:user_id, start_time=:start_time, 
+                      end_time=:end_time, duration=:duration, description=:description, 
+                      updated_at=:updated_at 
+                  WHERE id=:id";
+        
+        $stmt = $this->conn->prepare($query);
+        
+        $this->task_id = htmlspecialchars(strip_tags($this->task_id));
+        $this->user_id = htmlspecialchars(strip_tags($this->user_id));
+        $this->start_time = htmlspecialchars(strip_tags($this->start_time));
+        $this->end_time = htmlspecialchars(strip_tags($this->end_time));
+        $this->duration = htmlspecialchars(strip_tags($this->duration));
+        $this->description = htmlspecialchars(strip_tags($this->description));
+        $this->updated_at = gmdate('Y-m-d H:i:s');
+        
+        $stmt->bindParam(":task_id", $this->task_id);
+        $stmt->bindParam(":user_id", $this->user_id);
+        $stmt->bindParam(":start_time", $this->start_time);
+        $stmt->bindParam(":end_time", $this->end_time);
+        $stmt->bindParam(":duration", $this->duration);
+        $stmt->bindParam(":description", $this->description);
+        $stmt->bindParam(":updated_at", $this->updated_at);
+        $stmt->bindParam(":id", $this->id);
+        
+        if($stmt->execute()) {
+            return true;
+        }
+        
+        return false;
+    }
+    
+    public function delete() {
+        $query = "DELETE FROM " . $this->table_name . " WHERE id = ?";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->bindParam(1, $this->id);
+        
+        if($stmt->execute()) {
+            return true;
+        }
+        
+        return false;
+    }
+    
+    public function stop($id) {
+        $this->end_time = gmdate('Y-m-d H:i:s');
+        $this->duration = $this->calculateDuration($this->start_time, $this->end_time);
+        
+        $query = "UPDATE " . $this->table_name . " 
+                  SET end_time=:end_time, duration=:duration, updated_at=:updated_at 
+                  WHERE id=:id";
+        
+        $stmt = $this->conn->prepare($query);
+        $stmt->bindParam(":end_time", $this->end_time);
+        $stmt->bindParam(":duration", $this->duration);
+        $stmt->bindParam(":updated_at", gmdate('Y-m-d H:i:s'));
+        $stmt->bindParam(":id", $id);
+        
+        if($stmt->execute()) {
+            return true;
+        }
+        
+        return false;
+    }
+    
+    private function calculateDuration($start_time, $end_time) {
+        $start = new DateTime($start_time);
+        $end = new DateTime($end_time);
+        $interval = $start->diff($end);
+        
+        $hours = $interval->format('%H');
+        $minutes = $interval->format('%I');
+        $seconds = $interval->format('%s');
+        
+        return sprintf('%02d:%02d:%02d', $hours, $minutes, $seconds);
+    }
+}
+?>

+ 16 - 2
backend/models/WorkHour.php

@@ -8,6 +8,8 @@ class WorkHour {
     public $task_id;
     public $user_id;
     public $date;
+    public $start_time;
+    public $end_time;
     public $hours;
     public $description;
     public $rate;
@@ -20,6 +22,18 @@ class WorkHour {
     }
     
     public function create() {
+        // Calculate hours from start_time and end_time if provided
+        if ($this->start_time && $this->end_time) {
+            $start = new DateTime($this->start_time);
+            $end = new DateTime($this->end_time);
+            $interval = $start->diff($end);
+            $this->hours = $interval->format('%H:%I');
+            
+            // Convert to decimal hours for database storage
+            $parts = explode(':', $this->hours);
+            $this->hours = (float)$parts[0] + ((float)$parts[1] / 60);
+        }
+        
         // Auto-fetch client hour price if rate is not provided or empty
         if (!$this->rate || $this->rate === '') {
             $this->rate = $this->getClientHourPrice();
@@ -35,8 +49,8 @@ class WorkHour {
         
         $query = "INSERT INTO " . $this->table_name . " 
                   SET task_id=:task_id, user_id=:user_id, date=:date, hours=:hours, 
-                      description=:description, rate=:rate, total_amount=:total_amount, 
-                      created_at=:created_at, updated_at=:updated_at";
+                      description=:description, rate=:rate, total_amount=:total_amount, created_at=:created_at, 
+                      updated_at=:updated_at";
         
         $stmt = $this->conn->prepare($query);
         

+ 22 - 0
backend/vendor/autoload.php

@@ -0,0 +1,22 @@
+<?php
+
+// autoload.php @generated by Composer
+
+if (PHP_VERSION_ID < 50600) {
+    if (!headers_sent()) {
+        header('HTTP/1.1 500 Internal Server Error');
+    }
+    $err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
+    if (!ini_get('display_errors')) {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            fwrite(STDERR, $err);
+        } elseif (!headers_sent()) {
+            echo $err;
+        }
+    }
+    throw new RuntimeException($err);
+}
+
+require_once __DIR__ . '/composer/autoload_real.php';
+
+return ComposerAutoloaderInitbd8074657af1ad172e80126d2fc9f9da::getLoader();

+ 579 - 0
backend/vendor/composer/ClassLoader.php

@@ -0,0 +1,579 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer\Autoload;
+
+/**
+ * ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
+ *
+ *     $loader = new \Composer\Autoload\ClassLoader();
+ *
+ *     // register classes with namespaces
+ *     $loader->add('Symfony\Component', __DIR__.'/component');
+ *     $loader->add('Symfony',           __DIR__.'/framework');
+ *
+ *     // activate the autoloader
+ *     $loader->register();
+ *
+ *     // to enable searching the include path (eg. for PEAR packages)
+ *     $loader->setUseIncludePath(true);
+ *
+ * In this example, if you try to use a class in the Symfony\Component
+ * namespace or one of its children (Symfony\Component\Console for instance),
+ * the autoloader will first look for the class under the component/
+ * directory, and it will then fallback to the framework/ directory if not
+ * found before giving up.
+ *
+ * This class is loosely based on the Symfony UniversalClassLoader.
+ *
+ * @author Fabien Potencier <fabien@symfony.com>
+ * @author Jordi Boggiano <j.boggiano@seld.be>
+ * @see    https://www.php-fig.org/psr/psr-0/
+ * @see    https://www.php-fig.org/psr/psr-4/
+ */
+class ClassLoader
+{
+    /** @var \Closure(string):void */
+    private static $includeFile;
+
+    /** @var string|null */
+    private $vendorDir;
+
+    // PSR-4
+    /**
+     * @var array<string, array<string, int>>
+     */
+    private $prefixLengthsPsr4 = array();
+    /**
+     * @var array<string, list<string>>
+     */
+    private $prefixDirsPsr4 = array();
+    /**
+     * @var list<string>
+     */
+    private $fallbackDirsPsr4 = array();
+
+    // PSR-0
+    /**
+     * List of PSR-0 prefixes
+     *
+     * Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
+     *
+     * @var array<string, array<string, list<string>>>
+     */
+    private $prefixesPsr0 = array();
+    /**
+     * @var list<string>
+     */
+    private $fallbackDirsPsr0 = array();
+
+    /** @var bool */
+    private $useIncludePath = false;
+
+    /**
+     * @var array<string, string>
+     */
+    private $classMap = array();
+
+    /** @var bool */
+    private $classMapAuthoritative = false;
+
+    /**
+     * @var array<string, bool>
+     */
+    private $missingClasses = array();
+
+    /** @var string|null */
+    private $apcuPrefix;
+
+    /**
+     * @var array<string, self>
+     */
+    private static $registeredLoaders = array();
+
+    /**
+     * @param string|null $vendorDir
+     */
+    public function __construct($vendorDir = null)
+    {
+        $this->vendorDir = $vendorDir;
+        self::initializeIncludeClosure();
+    }
+
+    /**
+     * @return array<string, list<string>>
+     */
+    public function getPrefixes()
+    {
+        if (!empty($this->prefixesPsr0)) {
+            return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
+        }
+
+        return array();
+    }
+
+    /**
+     * @return array<string, list<string>>
+     */
+    public function getPrefixesPsr4()
+    {
+        return $this->prefixDirsPsr4;
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function getFallbackDirs()
+    {
+        return $this->fallbackDirsPsr0;
+    }
+
+    /**
+     * @return list<string>
+     */
+    public function getFallbackDirsPsr4()
+    {
+        return $this->fallbackDirsPsr4;
+    }
+
+    /**
+     * @return array<string, string> Array of classname => path
+     */
+    public function getClassMap()
+    {
+        return $this->classMap;
+    }
+
+    /**
+     * @param array<string, string> $classMap Class to filename map
+     *
+     * @return void
+     */
+    public function addClassMap(array $classMap)
+    {
+        if ($this->classMap) {
+            $this->classMap = array_merge($this->classMap, $classMap);
+        } else {
+            $this->classMap = $classMap;
+        }
+    }
+
+    /**
+     * Registers a set of PSR-0 directories for a given prefix, either
+     * appending or prepending to the ones previously set for this prefix.
+     *
+     * @param string              $prefix  The prefix
+     * @param list<string>|string $paths   The PSR-0 root directories
+     * @param bool                $prepend Whether to prepend the directories
+     *
+     * @return void
+     */
+    public function add($prefix, $paths, $prepend = false)
+    {
+        $paths = (array) $paths;
+        if (!$prefix) {
+            if ($prepend) {
+                $this->fallbackDirsPsr0 = array_merge(
+                    $paths,
+                    $this->fallbackDirsPsr0
+                );
+            } else {
+                $this->fallbackDirsPsr0 = array_merge(
+                    $this->fallbackDirsPsr0,
+                    $paths
+                );
+            }
+
+            return;
+        }
+
+        $first = $prefix[0];
+        if (!isset($this->prefixesPsr0[$first][$prefix])) {
+            $this->prefixesPsr0[$first][$prefix] = $paths;
+
+            return;
+        }
+        if ($prepend) {
+            $this->prefixesPsr0[$first][$prefix] = array_merge(
+                $paths,
+                $this->prefixesPsr0[$first][$prefix]
+            );
+        } else {
+            $this->prefixesPsr0[$first][$prefix] = array_merge(
+                $this->prefixesPsr0[$first][$prefix],
+                $paths
+            );
+        }
+    }
+
+    /**
+     * Registers a set of PSR-4 directories for a given namespace, either
+     * appending or prepending to the ones previously set for this namespace.
+     *
+     * @param string              $prefix  The prefix/namespace, with trailing '\\'
+     * @param list<string>|string $paths   The PSR-4 base directories
+     * @param bool                $prepend Whether to prepend the directories
+     *
+     * @throws \InvalidArgumentException
+     *
+     * @return void
+     */
+    public function addPsr4($prefix, $paths, $prepend = false)
+    {
+        $paths = (array) $paths;
+        if (!$prefix) {
+            // Register directories for the root namespace.
+            if ($prepend) {
+                $this->fallbackDirsPsr4 = array_merge(
+                    $paths,
+                    $this->fallbackDirsPsr4
+                );
+            } else {
+                $this->fallbackDirsPsr4 = array_merge(
+                    $this->fallbackDirsPsr4,
+                    $paths
+                );
+            }
+        } elseif (!isset($this->prefixDirsPsr4[$prefix])) {
+            // Register directories for a new namespace.
+            $length = strlen($prefix);
+            if ('\\' !== $prefix[$length - 1]) {
+                throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+            }
+            $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+            $this->prefixDirsPsr4[$prefix] = $paths;
+        } elseif ($prepend) {
+            // Prepend directories for an already registered namespace.
+            $this->prefixDirsPsr4[$prefix] = array_merge(
+                $paths,
+                $this->prefixDirsPsr4[$prefix]
+            );
+        } else {
+            // Append directories for an already registered namespace.
+            $this->prefixDirsPsr4[$prefix] = array_merge(
+                $this->prefixDirsPsr4[$prefix],
+                $paths
+            );
+        }
+    }
+
+    /**
+     * Registers a set of PSR-0 directories for a given prefix,
+     * replacing any others previously set for this prefix.
+     *
+     * @param string              $prefix The prefix
+     * @param list<string>|string $paths  The PSR-0 base directories
+     *
+     * @return void
+     */
+    public function set($prefix, $paths)
+    {
+        if (!$prefix) {
+            $this->fallbackDirsPsr0 = (array) $paths;
+        } else {
+            $this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
+        }
+    }
+
+    /**
+     * Registers a set of PSR-4 directories for a given namespace,
+     * replacing any others previously set for this namespace.
+     *
+     * @param string              $prefix The prefix/namespace, with trailing '\\'
+     * @param list<string>|string $paths  The PSR-4 base directories
+     *
+     * @throws \InvalidArgumentException
+     *
+     * @return void
+     */
+    public function setPsr4($prefix, $paths)
+    {
+        if (!$prefix) {
+            $this->fallbackDirsPsr4 = (array) $paths;
+        } else {
+            $length = strlen($prefix);
+            if ('\\' !== $prefix[$length - 1]) {
+                throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
+            }
+            $this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
+            $this->prefixDirsPsr4[$prefix] = (array) $paths;
+        }
+    }
+
+    /**
+     * Turns on searching the include path for class files.
+     *
+     * @param bool $useIncludePath
+     *
+     * @return void
+     */
+    public function setUseIncludePath($useIncludePath)
+    {
+        $this->useIncludePath = $useIncludePath;
+    }
+
+    /**
+     * Can be used to check if the autoloader uses the include path to check
+     * for classes.
+     *
+     * @return bool
+     */
+    public function getUseIncludePath()
+    {
+        return $this->useIncludePath;
+    }
+
+    /**
+     * Turns off searching the prefix and fallback directories for classes
+     * that have not been registered with the class map.
+     *
+     * @param bool $classMapAuthoritative
+     *
+     * @return void
+     */
+    public function setClassMapAuthoritative($classMapAuthoritative)
+    {
+        $this->classMapAuthoritative = $classMapAuthoritative;
+    }
+
+    /**
+     * Should class lookup fail if not found in the current class map?
+     *
+     * @return bool
+     */
+    public function isClassMapAuthoritative()
+    {
+        return $this->classMapAuthoritative;
+    }
+
+    /**
+     * APCu prefix to use to cache found/not-found classes, if the extension is enabled.
+     *
+     * @param string|null $apcuPrefix
+     *
+     * @return void
+     */
+    public function setApcuPrefix($apcuPrefix)
+    {
+        $this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
+    }
+
+    /**
+     * The APCu prefix in use, or null if APCu caching is not enabled.
+     *
+     * @return string|null
+     */
+    public function getApcuPrefix()
+    {
+        return $this->apcuPrefix;
+    }
+
+    /**
+     * Registers this instance as an autoloader.
+     *
+     * @param bool $prepend Whether to prepend the autoloader or not
+     *
+     * @return void
+     */
+    public function register($prepend = false)
+    {
+        spl_autoload_register(array($this, 'loadClass'), true, $prepend);
+
+        if (null === $this->vendorDir) {
+            return;
+        }
+
+        if ($prepend) {
+            self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
+        } else {
+            unset(self::$registeredLoaders[$this->vendorDir]);
+            self::$registeredLoaders[$this->vendorDir] = $this;
+        }
+    }
+
+    /**
+     * Unregisters this instance as an autoloader.
+     *
+     * @return void
+     */
+    public function unregister()
+    {
+        spl_autoload_unregister(array($this, 'loadClass'));
+
+        if (null !== $this->vendorDir) {
+            unset(self::$registeredLoaders[$this->vendorDir]);
+        }
+    }
+
+    /**
+     * Loads the given class or interface.
+     *
+     * @param  string    $class The name of the class
+     * @return true|null True if loaded, null otherwise
+     */
+    public function loadClass($class)
+    {
+        if ($file = $this->findFile($class)) {
+            $includeFile = self::$includeFile;
+            $includeFile($file);
+
+            return true;
+        }
+
+        return null;
+    }
+
+    /**
+     * Finds the path to the file where the class is defined.
+     *
+     * @param string $class The name of the class
+     *
+     * @return string|false The path if found, false otherwise
+     */
+    public function findFile($class)
+    {
+        // class map lookup
+        if (isset($this->classMap[$class])) {
+            return $this->classMap[$class];
+        }
+        if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
+            return false;
+        }
+        if (null !== $this->apcuPrefix) {
+            $file = apcu_fetch($this->apcuPrefix.$class, $hit);
+            if ($hit) {
+                return $file;
+            }
+        }
+
+        $file = $this->findFileWithExtension($class, '.php');
+
+        // Search for Hack files if we are running on HHVM
+        if (false === $file && defined('HHVM_VERSION')) {
+            $file = $this->findFileWithExtension($class, '.hh');
+        }
+
+        if (null !== $this->apcuPrefix) {
+            apcu_add($this->apcuPrefix.$class, $file);
+        }
+
+        if (false === $file) {
+            // Remember that this class does not exist.
+            $this->missingClasses[$class] = true;
+        }
+
+        return $file;
+    }
+
+    /**
+     * Returns the currently registered loaders keyed by their corresponding vendor directories.
+     *
+     * @return array<string, self>
+     */
+    public static function getRegisteredLoaders()
+    {
+        return self::$registeredLoaders;
+    }
+
+    /**
+     * @param  string       $class
+     * @param  string       $ext
+     * @return string|false
+     */
+    private function findFileWithExtension($class, $ext)
+    {
+        // PSR-4 lookup
+        $logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
+
+        $first = $class[0];
+        if (isset($this->prefixLengthsPsr4[$first])) {
+            $subPath = $class;
+            while (false !== $lastPos = strrpos($subPath, '\\')) {
+                $subPath = substr($subPath, 0, $lastPos);
+                $search = $subPath . '\\';
+                if (isset($this->prefixDirsPsr4[$search])) {
+                    $pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
+                    foreach ($this->prefixDirsPsr4[$search] as $dir) {
+                        if (file_exists($file = $dir . $pathEnd)) {
+                            return $file;
+                        }
+                    }
+                }
+            }
+        }
+
+        // PSR-4 fallback dirs
+        foreach ($this->fallbackDirsPsr4 as $dir) {
+            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
+                return $file;
+            }
+        }
+
+        // PSR-0 lookup
+        if (false !== $pos = strrpos($class, '\\')) {
+            // namespaced class name
+            $logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
+                . strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
+        } else {
+            // PEAR-like class name
+            $logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
+        }
+
+        if (isset($this->prefixesPsr0[$first])) {
+            foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
+                if (0 === strpos($class, $prefix)) {
+                    foreach ($dirs as $dir) {
+                        if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+                            return $file;
+                        }
+                    }
+                }
+            }
+        }
+
+        // PSR-0 fallback dirs
+        foreach ($this->fallbackDirsPsr0 as $dir) {
+            if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
+                return $file;
+            }
+        }
+
+        // PSR-0 include paths.
+        if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
+            return $file;
+        }
+
+        return false;
+    }
+
+    /**
+     * @return void
+     */
+    private static function initializeIncludeClosure()
+    {
+        if (self::$includeFile !== null) {
+            return;
+        }
+
+        /**
+         * Scope isolated include.
+         *
+         * Prevents access to $this/self from included files.
+         *
+         * @param  string $file
+         * @return void
+         */
+        self::$includeFile = \Closure::bind(static function($file) {
+            include $file;
+        }, null, null);
+    }
+}

+ 396 - 0
backend/vendor/composer/InstalledVersions.php

@@ -0,0 +1,396 @@
+<?php
+
+/*
+ * This file is part of Composer.
+ *
+ * (c) Nils Adermann <naderman@naderman.de>
+ *     Jordi Boggiano <j.boggiano@seld.be>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Composer;
+
+use Composer\Autoload\ClassLoader;
+use Composer\Semver\VersionParser;
+
+/**
+ * This class is copied in every Composer installed project and available to all
+ *
+ * See also https://getcomposer.org/doc/07-runtime.md#installed-versions
+ *
+ * To require its presence, you can require `composer-runtime-api ^2.0`
+ *
+ * @final
+ */
+class InstalledVersions
+{
+    /**
+     * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to
+     * @internal
+     */
+    private static $selfDir = null;
+
+    /**
+     * @var mixed[]|null
+     * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
+     */
+    private static $installed;
+
+    /**
+     * @var bool
+     */
+    private static $installedIsLocalDir;
+
+    /**
+     * @var bool|null
+     */
+    private static $canGetVendors;
+
+    /**
+     * @var array[]
+     * @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+     */
+    private static $installedByVendor = array();
+
+    /**
+     * Returns a list of all package names which are present, either by being installed, replaced or provided
+     *
+     * @return string[]
+     * @psalm-return list<string>
+     */
+    public static function getInstalledPackages()
+    {
+        $packages = array();
+        foreach (self::getInstalled() as $installed) {
+            $packages[] = array_keys($installed['versions']);
+        }
+
+        if (1 === \count($packages)) {
+            return $packages[0];
+        }
+
+        return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
+    }
+
+    /**
+     * Returns a list of all package names with a specific type e.g. 'library'
+     *
+     * @param  string   $type
+     * @return string[]
+     * @psalm-return list<string>
+     */
+    public static function getInstalledPackagesByType($type)
+    {
+        $packagesByType = array();
+
+        foreach (self::getInstalled() as $installed) {
+            foreach ($installed['versions'] as $name => $package) {
+                if (isset($package['type']) && $package['type'] === $type) {
+                    $packagesByType[] = $name;
+                }
+            }
+        }
+
+        return $packagesByType;
+    }
+
+    /**
+     * Checks whether the given package is installed
+     *
+     * This also returns true if the package name is provided or replaced by another package
+     *
+     * @param  string $packageName
+     * @param  bool   $includeDevRequirements
+     * @return bool
+     */
+    public static function isInstalled($packageName, $includeDevRequirements = true)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (isset($installed['versions'][$packageName])) {
+                return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Checks whether the given package satisfies a version constraint
+     *
+     * e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
+     *
+     *   Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
+     *
+     * @param  VersionParser $parser      Install composer/semver to have access to this class and functionality
+     * @param  string        $packageName
+     * @param  string|null   $constraint  A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
+     * @return bool
+     */
+    public static function satisfies(VersionParser $parser, $packageName, $constraint)
+    {
+        $constraint = $parser->parseConstraints((string) $constraint);
+        $provided = $parser->parseConstraints(self::getVersionRanges($packageName));
+
+        return $provided->matches($constraint);
+    }
+
+    /**
+     * Returns a version constraint representing all the range(s) which are installed for a given package
+     *
+     * It is easier to use this via isInstalled() with the $constraint argument if you need to check
+     * whether a given version of a package is installed, and not just whether it exists
+     *
+     * @param  string $packageName
+     * @return string Version constraint usable with composer/semver
+     */
+    public static function getVersionRanges($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            $ranges = array();
+            if (isset($installed['versions'][$packageName]['pretty_version'])) {
+                $ranges[] = $installed['versions'][$packageName]['pretty_version'];
+            }
+            if (array_key_exists('aliases', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
+            }
+            if (array_key_exists('replaced', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
+            }
+            if (array_key_exists('provided', $installed['versions'][$packageName])) {
+                $ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
+            }
+
+            return implode(' || ', $ranges);
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+     */
+    public static function getVersion($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['version'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['version'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
+     */
+    public static function getPrettyVersion($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['pretty_version'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['pretty_version'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
+     */
+    public static function getReference($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            if (!isset($installed['versions'][$packageName]['reference'])) {
+                return null;
+            }
+
+            return $installed['versions'][$packageName]['reference'];
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @param  string      $packageName
+     * @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
+     */
+    public static function getInstallPath($packageName)
+    {
+        foreach (self::getInstalled() as $installed) {
+            if (!isset($installed['versions'][$packageName])) {
+                continue;
+            }
+
+            return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
+        }
+
+        throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
+    }
+
+    /**
+     * @return array
+     * @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
+     */
+    public static function getRootPackage()
+    {
+        $installed = self::getInstalled();
+
+        return $installed[0]['root'];
+    }
+
+    /**
+     * Returns the raw installed.php data for custom implementations
+     *
+     * @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
+     * @return array[]
+     * @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
+     */
+    public static function getRawData()
+    {
+        @trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
+
+        if (null === self::$installed) {
+            // only require the installed.php file if this file is loaded from its dumped location,
+            // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+            if (substr(__DIR__, -8, 1) !== 'C') {
+                self::$installed = include __DIR__ . '/installed.php';
+            } else {
+                self::$installed = array();
+            }
+        }
+
+        return self::$installed;
+    }
+
+    /**
+     * Returns the raw data of all installed.php which are currently loaded for custom implementations
+     *
+     * @return array[]
+     * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+     */
+    public static function getAllRawData()
+    {
+        return self::getInstalled();
+    }
+
+    /**
+     * Lets you reload the static array from another file
+     *
+     * This is only useful for complex integrations in which a project needs to use
+     * this class but then also needs to execute another project's autoloader in process,
+     * and wants to ensure both projects have access to their version of installed.php.
+     *
+     * A typical case would be PHPUnit, where it would need to make sure it reads all
+     * the data it needs from this class, then call reload() with
+     * `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
+     * the project in which it runs can then also use this class safely, without
+     * interference between PHPUnit's dependencies and the project's dependencies.
+     *
+     * @param  array[] $data A vendor/composer/installed.php data set
+     * @return void
+     *
+     * @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
+     */
+    public static function reload($data)
+    {
+        self::$installed = $data;
+        self::$installedByVendor = array();
+
+        // when using reload, we disable the duplicate protection to ensure that self::$installed data is
+        // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
+        // so we have to assume it does not, and that may result in duplicate data being returned when listing
+        // all installed packages for example
+        self::$installedIsLocalDir = false;
+    }
+
+    /**
+     * @return string
+     */
+    private static function getSelfDir()
+    {
+        if (self::$selfDir === null) {
+            self::$selfDir = strtr(__DIR__, '\\', '/');
+        }
+
+        return self::$selfDir;
+    }
+
+    /**
+     * @return array[]
+     * @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
+     */
+    private static function getInstalled()
+    {
+        if (null === self::$canGetVendors) {
+            self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
+        }
+
+        $installed = array();
+        $copiedLocalDir = false;
+
+        if (self::$canGetVendors) {
+            $selfDir = self::getSelfDir();
+            foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
+                $vendorDir = strtr($vendorDir, '\\', '/');
+                if (isset(self::$installedByVendor[$vendorDir])) {
+                    $installed[] = self::$installedByVendor[$vendorDir];
+                } elseif (is_file($vendorDir.'/composer/installed.php')) {
+                    /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+                    $required = require $vendorDir.'/composer/installed.php';
+                    self::$installedByVendor[$vendorDir] = $required;
+                    $installed[] = $required;
+                    if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
+                        self::$installed = $required;
+                        self::$installedIsLocalDir = true;
+                    }
+                }
+                if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
+                    $copiedLocalDir = true;
+                }
+            }
+        }
+
+        if (null === self::$installed) {
+            // only require the installed.php file if this file is loaded from its dumped location,
+            // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
+            if (substr(__DIR__, -8, 1) !== 'C') {
+                /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
+                $required = require __DIR__ . '/installed.php';
+                self::$installed = $required;
+            } else {
+                self::$installed = array();
+            }
+        }
+
+        if (self::$installed !== array() && !$copiedLocalDir) {
+            $installed[] = self::$installed;
+        }
+
+        return $installed;
+    }
+}

+ 21 - 0
backend/vendor/composer/LICENSE

@@ -0,0 +1,21 @@
+
+Copyright (c) Nils Adermann, Jordi Boggiano
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished
+to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+

+ 10 - 0
backend/vendor/composer/autoload_classmap.php

@@ -0,0 +1,10 @@
+<?php
+
+// autoload_classmap.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = dirname($vendorDir);
+
+return array(
+    'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
+);

+ 9 - 0
backend/vendor/composer/autoload_namespaces.php

@@ -0,0 +1,9 @@
+<?php
+
+// autoload_namespaces.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = dirname($vendorDir);
+
+return array(
+);

+ 11 - 0
backend/vendor/composer/autoload_psr4.php

@@ -0,0 +1,11 @@
+<?php
+
+// autoload_psr4.php @generated by Composer
+
+$vendorDir = dirname(__DIR__);
+$baseDir = dirname($vendorDir);
+
+return array(
+    'Models\\' => array($baseDir . '/models'),
+    'App\\' => array($baseDir . '/src'),
+);

+ 38 - 0
backend/vendor/composer/autoload_real.php

@@ -0,0 +1,38 @@
+<?php
+
+// autoload_real.php @generated by Composer
+
+class ComposerAutoloaderInitbd8074657af1ad172e80126d2fc9f9da
+{
+    private static $loader;
+
+    public static function loadClassLoader($class)
+    {
+        if ('Composer\Autoload\ClassLoader' === $class) {
+            require __DIR__ . '/ClassLoader.php';
+        }
+    }
+
+    /**
+     * @return \Composer\Autoload\ClassLoader
+     */
+    public static function getLoader()
+    {
+        if (null !== self::$loader) {
+            return self::$loader;
+        }
+
+        require __DIR__ . '/platform_check.php';
+
+        spl_autoload_register(array('ComposerAutoloaderInitbd8074657af1ad172e80126d2fc9f9da', 'loadClassLoader'), true, true);
+        self::$loader = $loader = new \Composer\Autoload\ClassLoader(\dirname(__DIR__));
+        spl_autoload_unregister(array('ComposerAutoloaderInitbd8074657af1ad172e80126d2fc9f9da', 'loadClassLoader'));
+
+        require __DIR__ . '/autoload_static.php';
+        call_user_func(\Composer\Autoload\ComposerStaticInitbd8074657af1ad172e80126d2fc9f9da::getInitializer($loader));
+
+        $loader->register(true);
+
+        return $loader;
+    }
+}

+ 44 - 0
backend/vendor/composer/autoload_static.php

@@ -0,0 +1,44 @@
+<?php
+
+// autoload_static.php @generated by Composer
+
+namespace Composer\Autoload;
+
+class ComposerStaticInitbd8074657af1ad172e80126d2fc9f9da
+{
+    public static $prefixLengthsPsr4 = array (
+        'M' =>
+        array (
+            'Models\\' => 7,
+        ),
+        'A' =>
+        array (
+            'App\\' => 4,
+        ),
+    );
+
+    public static $prefixDirsPsr4 = array (
+        'Models\\' =>
+        array (
+            0 => __DIR__ . '/../..' . '/models',
+        ),
+        'App\\' =>
+        array (
+            0 => __DIR__ . '/../..' . '/src',
+        ),
+    );
+
+    public static $classMap = array (
+        'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
+    );
+
+    public static function getInitializer(ClassLoader $loader)
+    {
+        return \Closure::bind(function () use ($loader) {
+            $loader->prefixLengthsPsr4 = ComposerStaticInitbd8074657af1ad172e80126d2fc9f9da::$prefixLengthsPsr4;
+            $loader->prefixDirsPsr4 = ComposerStaticInitbd8074657af1ad172e80126d2fc9f9da::$prefixDirsPsr4;
+            $loader->classMap = ComposerStaticInitbd8074657af1ad172e80126d2fc9f9da::$classMap;
+
+        }, null, ClassLoader::class);
+    }
+}

+ 5 - 0
backend/vendor/composer/installed.json

@@ -0,0 +1,5 @@
+{
+    "packages": [],
+    "dev": false,
+    "dev-package-names": []
+}

+ 23 - 0
backend/vendor/composer/installed.php

@@ -0,0 +1,23 @@
+<?php return array(
+    'root' => array(
+        'name' => 'inventory/backend',
+        'pretty_version' => 'dev-master',
+        'version' => 'dev-master',
+        'reference' => '27500ebaca5c11a350e9d8878219e30d3cef7f38',
+        'type' => 'project',
+        'install_path' => __DIR__ . '/../../',
+        'aliases' => array(),
+        'dev' => false,
+    ),
+    'versions' => array(
+        'inventory/backend' => array(
+            'pretty_version' => 'dev-master',
+            'version' => 'dev-master',
+            'reference' => '27500ebaca5c11a350e9d8878219e30d3cef7f38',
+            'type' => 'project',
+            'install_path' => __DIR__ . '/../../',
+            'aliases' => array(),
+            'dev_requirement' => false,
+        ),
+    ),
+);

+ 25 - 0
backend/vendor/composer/platform_check.php

@@ -0,0 +1,25 @@
+<?php
+
+// platform_check.php @generated by Composer
+
+$issues = array();
+
+if (!(PHP_VERSION_ID >= 80100)) {
+    $issues[] = 'Your Composer dependencies require a PHP version ">= 8.1.0". You are running ' . PHP_VERSION . '.';
+}
+
+if ($issues) {
+    if (!headers_sent()) {
+        header('HTTP/1.1 500 Internal Server Error');
+    }
+    if (!ini_get('display_errors')) {
+        if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
+            fwrite(STDERR, 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . implode(PHP_EOL, $issues) . PHP_EOL.PHP_EOL);
+        } elseif (!headers_sent()) {
+            echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL;
+        }
+    }
+    throw new \RuntimeException(
+        'Composer detected issues in your platform: ' . implode(' ', $issues)
+    );
+}

BIN
composer.phar


+ 24 - 22
docker-compose.yml

@@ -1,20 +1,20 @@
 version: '3.8'
 
 services:
-  # Backend PHP Service
-  backend:
+  # Complete Inventory Solution (Single Container)
+  inventory-app:
     build:
       context: .
       dockerfile: Dockerfile
-    container_name: inventory-backend
+    container_name: inventory-app
     ports:
-      - "${BACKEND_PORT:-8080}:80"
+      - "${APP_PORT:-80}:80"
     environment:
-      - DB_HOST=${DB_HOST}
-      - DB_PORT=${DB_PORT}
-      - DB_NAME=${DB_NAME}
-      - DB_USER=${DB_USER}
-      - DB_PASS=${DB_PASS}
+      - DB_HOST=${DB_HOST:-db}
+      - DB_PORT=${DB_PORT:-3306}
+      - DB_NAME=${DB_NAME:-inventory_db}
+      - DB_USER=${DB_USER:-inventory_db}
+      - DB_PASS=${DB_PASS:-mDw(HF]Cub.UM2*7}
       - COMPANY_NAME=${COMPANY_NAME:-Inventory Management}
       - COMPANY_ADDRESS=${COMPANY_ADDRESS:-123 Business St}
       - COMPANY_CITY=${COMPANY_CITY:-Helsinki}
@@ -27,7 +27,6 @@ services:
       - ALLOWED_FILE_TYPES=${ALLOWED_FILE_TYPES:-pdf,doc,docx,xls,xlsx,jpg,jpeg,png,gif}
     volumes:
       - uploads_data:/var/www/html/uploads
-      - ./backend:/var/www/html
     networks:
       - inventory-network
     restart: unless-stopped
@@ -38,24 +37,25 @@ services:
       retries: 3
       start_period: 40s
 
-  # Frontend Vue.js Service
-  frontend:
-    build:
-      context: ./frontend
-      dockerfile: Dockerfile
-    container_name: inventory-frontend
+  # MySQL Database Service
+  db:
+    image: mysql:8.0
+    container_name: inventory-db
     ports:
-      - "${FRONTEND_PORT:-3000}:80"
+      - "${DB_PORT:-3306}:3306"
     environment:
-      - VUE_APP_API_URL=${VUE_APP_API_URL:-http://localhost:9123}
-    depends_on:
-      backend:
-        condition: service_healthy
+      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD:-rootpassword}
+      - MYSQL_DATABASE=${DB_NAME:-inventory_db}
+      - MYSQL_USER=${DB_USER:-inventory_db}
+      - MYSQL_PASSWORD=${DB_PASS:-mDw(HF]Cub.UM2*7}
+    volumes:
+      - mysql_data:/var/lib/mysql
+      - ./backend/migrate_complete.sql:/docker-entrypoint-initdb.d/init.sql
     networks:
       - inventory-network
     restart: unless-stopped
     healthcheck:
-      test: ["CMD", "curl", "-f", "http://localhost/"]
+      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
       interval: 30s
       timeout: 10s
       retries: 3
@@ -75,6 +75,8 @@ services:
     command: redis-server --appendonly yes
 
 volumes:
+  mysql_data:
+    driver: local
   redis_data:
     driver: local
   uploads_data:

+ 3 - 0
docker/apache-unified-simple.conf

@@ -2,6 +2,9 @@
     ServerName inventory.local
     DocumentRoot /var/www/html
     
+    # Set proper directory index for frontend
+    DirectoryIndex index.html index.php
+    
     # Enable CORS headers for API requests
     Header always set Access-Control-Allow-Origin "*"
     Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"

+ 27 - 2
docker/apache.conf

@@ -1,10 +1,24 @@
 <VirtualHost *:80>
     ServerName localhost
-    DocumentRoot /var/www/html
+    DocumentRoot /var/www/html/frontend/dist
 
-    <Directory /var/www/html>
+    # Serve Vue.js frontend (SPA)
+    <Directory /var/www/html/frontend/dist>
         AllowOverride All
         Require all granted
+        
+        # Handle Vue.js routing - redirect all non-file requests to index.html
+        RewriteEngine On
+        RewriteCond %{REQUEST_FILENAME} !-f
+        RewriteCond %{REQUEST_FILENAME} !-d
+        RewriteRule . /index.html [L]
+    </Directory>
+
+    # Handle PHP API endpoints
+    <Directory "/var/www/html/api">
+        AllowOverride All
+        Require all granted
+        Options -Indexes
     </Directory>
 
     # Enable CORS for API endpoints
@@ -25,6 +39,17 @@
         Header always set Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" env=CORs
     </IfModule>
 
+    # API endpoint routing
+    Alias /api /var/www/html/api
+    
+    # Handle uploads directory
+    Alias /uploads /var/www/html/uploads
+    <Directory "/var/www/html/uploads">
+        AllowOverride All
+        Require all granted
+        Options -Indexes
+    </Directory>
+
     ErrorLog ${APACHE_LOG_DIR}/error.log
     CustomLog ${APACHE_LOG_DIR}/access.log combined
 </VirtualHost>

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
frontend/dist/assets/index-4dff708e.css


Fișier diff suprimat deoarece este prea mare
+ 4 - 0
frontend/dist/assets/index-ada8cdd4.js


+ 203 - 0
frontend/dist/index.html

@@ -0,0 +1,203 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Inventory Management</title>
+    <style>
+        * {
+            margin: 0;
+            padding: 0;
+            box-sizing: border-box;
+        }
+        
+        body {
+            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+            background-color: #f5f5f5;
+            color: #333;
+        }
+        
+        .container {
+            max-width: 1200px;
+            margin: 0 auto;
+            padding: 20px;
+        }
+        
+        header {
+            background-color: #2c3e50;
+            color: white;
+            padding: 1rem 0;
+            margin-bottom: 2rem;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+        }
+        
+        h1 {
+            text-align: center;
+            font-size: 2.5rem;
+            font-weight: 300;
+        }
+        
+        .btn {
+            padding: 8px 16px;
+            border: none;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 14px;
+            transition: background-color 0.3s;
+        }
+        
+        .btn-primary {
+            background-color: #3498db;
+            color: white;
+        }
+        
+        .btn-primary:hover {
+            background-color: #2980b9;
+        }
+        
+        .btn-success {
+            background-color: #27ae60;
+            color: white;
+        }
+        
+        .btn-success:hover {
+            background-color: #229954;
+        }
+        
+        .btn-danger {
+            background-color: #e74c3c;
+            color: white;
+        }
+        
+        .btn-danger:hover {
+            background-color: #c0392b;
+        }
+        
+        .btn-warning {
+            background-color: #f39c12;
+            color: white;
+        }
+        
+        .btn-warning:hover {
+            background-color: #e67e22;
+        }
+        
+        .form-group {
+            margin-bottom: 1rem;
+        }
+        
+        label {
+            display: block;
+            margin-bottom: 5px;
+            font-weight: 500;
+        }
+        
+        input, textarea, select {
+            width: 100%;
+            padding: 8px 12px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            font-size: 14px;
+        }
+        
+        textarea {
+            resize: vertical;
+            min-height: 80px;
+        }
+        
+        .card {
+            background: white;
+            border-radius: 8px;
+            padding: 20px;
+            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+            margin-bottom: 20px;
+        }
+        
+        .table {
+            width: 100%;
+            border-collapse: collapse;
+            margin-top: 20px;
+        }
+        
+        .table th,
+        .table td {
+            padding: 12px;
+            text-align: left;
+            border-bottom: 1px solid #ddd;
+        }
+        
+        .table th {
+            background-color: #f8f9fa;
+            font-weight: 600;
+        }
+        
+        .table tr:hover {
+            background-color: #f8f9fa;
+        }
+        
+        .actions {
+            display: flex;
+            gap: 8px;
+        }
+        
+        .modal {
+            display: none;
+            position: fixed;
+            z-index: 1000;
+            left: 0;
+            top: 0;
+            width: 100%;
+            height: 100%;
+            background-color: rgba(0,0,0,0.5);
+        }
+        
+        .modal-content {
+            background-color: white;
+            margin: 10% auto;
+            padding: 20px;
+            border-radius: 8px;
+            width: 90%;
+            max-width: 500px;
+        }
+        
+        .close {
+            color: #aaa;
+            float: right;
+            font-size: 28px;
+            font-weight: bold;
+            cursor: pointer;
+        }
+        
+        .close:hover {
+            color: black;
+        }
+        
+        .loading {
+            text-align: center;
+            padding: 20px;
+        }
+        
+        .error {
+            color: #e74c3c;
+            background-color: #fdf2f2;
+            padding: 10px;
+            border-radius: 4px;
+            margin-bottom: 10px;
+        }
+        
+        .success {
+            color: #27ae60;
+            background-color: #f2f9f4;
+            padding: 10px;
+            border-radius: 4px;
+            margin-bottom: 10px;
+        }
+    </style>
+  <script type="module" crossorigin src="/assets/index-ada8cdd4.js"></script>
+  <link rel="stylesheet" href="/assets/index-4dff708e.css">
+</head>
+<body>
+    <div id="app"></div>
+    
+</body>
+</html>

+ 1 - 1
frontend/index.html

@@ -18,7 +18,7 @@
         }
         
         .container {
-            max-width: 1200px;
+            max-width: 1400px;
             margin: 0 auto;
             padding: 20px;
         }

+ 2 - 2
frontend/node_modules/.vite/deps/_metadata.json

@@ -5,13 +5,13 @@
     "axios": {
       "src": "../../axios/index.js",
       "file": "axios.js",
-      "fileHash": "a12bbfea",
+      "fileHash": "8cb7a977",
       "needsInterop": false
     },
     "vue": {
       "src": "../../vue/dist/vue.runtime.esm-bundler.js",
       "file": "vue.js",
-      "fileHash": "ac1136bc",
+      "fileHash": "b53d4509",
       "needsInterop": false
     }
   },

+ 1 - 1
frontend/src/api/axios.js

@@ -2,7 +2,7 @@ import axios from 'axios'
 
 // Create axios instance with base URL
 const api = axios.create({
-  baseURL: import.meta.env.VUE_APP_API_URL || '/api',
+  baseURL: import.meta.env.VUE_APP_API_URL || '',
   timeout: 10000,
   headers: {
     'Content-Type': 'application/json'

+ 514 - 0
frontend/src/components/HeaderTimer.vue

@@ -0,0 +1,514 @@
+<template>
+  <div class="header-timer">
+    <!-- Timer Running State -->
+    <div v-if="hasActiveTimer" class="timer-display">
+      <div class="timer-info">
+        <div class="timer-status running">
+          <i class="fas fa-circle"></i>
+          Ajastin käynnissä
+        </div>
+        <div class="timer-duration-large">
+          {{ formatTimerDuration(activeTimers[0]) }}
+        </div>
+      </div>
+      <div class="timer-controls">
+        <button 
+          @click="stopTimer(activeTimers[0]?.id)" 
+          class="btn btn-sm btn-danger"
+          title="Pysäytä ajastin"
+        >
+          <i class="fas fa-stop"></i>
+          Pysäytä
+        </button>
+        <TimerManagement />
+      </div>
+    </div>
+    
+    <!-- No Active Timer -->
+    <div v-else class="no-timer">
+      <button @click="startTimerDirect" class="btn btn-sm btn-success" title="Käynnistä ajastin">
+        <i class="fas fa-stopwatch"></i>
+        <span class="timer-text">Käynnistä ajastin</span>
+      </button>
+      <TimerManagement />
+    </div>
+    
+    <!-- Timer Modal -->
+    <div v-if="showModal" class="modal" @click.self="closeModal">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h3>Ajastin</h3>
+          <button @click="closeModal" class="close-btn">&times;</button>
+        </div>
+        
+        <div class="modal-body">
+          <div v-if="selectedTask" class="selected-task">
+            <h4>Tehtävä: {{ selectedTask.title }}</h4>
+          </div>
+          
+          <!-- Task Selection -->
+          <div v-if="!selectedTask && tasks.length > 0" class="task-selection">
+            <label>Valitse tehtävä (valinnainen):</label>
+            <select v-model="selectedTaskId" @change="selectTask" class="form-control">
+              <option value="">Ajasta ilman tehtävää</option>
+              <option v-for="task in tasks" :key="task.id" :value="task.id">
+                {{ task.title }}
+              </option>
+            </select>
+          </div>
+          
+          <!-- Timer Display -->
+          <div v-if="activeTimers.length > 0" class="timer-display-large">
+            <div class="timer-info">
+              <div class="timer-status" :class="{ 'timer-running': isTimerRunning }">
+                <i class="fas fa-circle"></i>
+                {{ isTimerRunning ? 'Ajastin käynnissä' : 'Ajastin pysäytetty' }}
+              </div>
+              <div class="timer-duration">
+                {{ formatTimerDuration(activeTimers[0]) }}
+              </div>
+            </div>
+          </div>
+          
+          <!-- Timer Controls -->
+          <div class="timer-controls-large">
+            <button 
+              v-if="!hasActiveTimer" 
+              @click="startTimer" 
+              class="btn btn-success"
+            >
+              <i class="fas fa-play"></i>
+              Aloita ajastin
+            </button>
+            <button 
+              v-if="isTimerRunning" 
+              @click="stopTimer(activeTimers[0]?.id)" 
+              class="btn btn-danger"
+            >
+              <i class="fas fa-stop"></i>
+              Pysäytä ajastin
+            </button>
+          </div>
+          
+          <!-- No tasks message -->
+          <div v-if="tasks.length === 0" class="no-tasks-message">
+            <p>Ei tehtäviä löytynyt. Luo ensin tehtävä ennen ajastimen käyttöä.</p>
+            <button @click="$emit('navigate-to-tasks')" class="btn btn-primary">
+              Siirry tehtäviin
+            </button>
+          </div>
+        </div>
+        
+        <div class="modal-footer">
+          <button @click="closeModal" class="btn btn-secondary">Sulje</button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from '../api/axios'
+import TimerManagement from './TimerManagement.vue'
+
+export default {
+  name: 'HeaderTimer',
+  components: {
+    TimerManagement
+  },
+  data() {
+    return {
+      showModal: false,
+      activeTimers: [],
+      tasks: [],
+      selectedTask: null,
+      selectedTaskId: '',
+      loading: false
+    }
+  },
+  computed: {
+    isTimerRunning() {
+      return this.activeTimers.length > 0 && this.activeTimers[0]?.end_time === null
+    },
+    hasActiveTimer() {
+      return this.activeTimers.some(timer => !timer.end_time)
+    },
+  },
+  mounted() {
+    this.loadActiveTimers()
+    this.loadTasks()
+    // Update timer every second
+    this.timerInterval = setInterval(() => {
+      if (this.hasActiveTimer) {
+        this.$forceUpdate() // Force update to refresh timer display
+      }
+    }, 1000)
+  },
+  beforeUnmount() {
+    if (this.timerInterval) {
+      clearInterval(this.timerInterval)
+    }
+  },
+  methods: {
+    async loadActiveTimers() {
+      try {
+        const response = await axios.get('/api/timers.php?action=active')
+        if (response.data.success) {
+          this.activeTimers = response.data.data || []
+        } else {
+          this.activeTimers = []
+        }
+      } catch (error) {
+        console.error('Error loading active timers:', error)
+        this.activeTimers = []
+      }
+    },
+    
+    async loadTasks() {
+      try {
+        const response = await axios.get('/api/tasks.php')
+        if (response.data.success) {
+          this.tasks = response.data.data || []
+        }
+      } catch (error) {
+        console.error('Error loading tasks:', error)
+        this.tasks = []
+      }
+    },
+    
+    showTimerModal() {
+      this.showModal = true
+      this.loadActiveTimers()
+      this.loadTasks()
+    },
+    
+    closeModal() {
+      this.showModal = false
+      this.selectedTask = null
+      this.selectedTaskId = ''
+    },
+    
+    selectTask() {
+      if (this.selectedTaskId) {
+        this.selectedTask = this.tasks.find(task => task.id === parseInt(this.selectedTaskId))
+      } else {
+        this.selectedTask = null
+      }
+    },
+    
+    async startTimerDirect() {
+      try {
+        const response = await axios.post('/api/timers.php', {
+          action: 'start',
+          task_id: null,
+          user_id: 1,
+          description: 'Timer started without task'
+        })
+        
+        if (response.data.success) {
+          this.loadActiveTimers()
+        } else {
+          console.error('Error starting timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error starting timer:', error)
+      }
+    },
+
+    async startTimer() {
+      try {
+        const taskDescription = this.selectedTask ? `Timer started for ${this.selectedTask.title}` : 'Timer started without task'
+        
+        const response = await axios.post('/api/timers.php', {
+          action: 'start',
+          task_id: this.selectedTask ? this.selectedTask.id : null,
+          user_id: 1, // Default to current user
+          description: taskDescription
+        })
+        
+        if (response.data.success) {
+          this.loadActiveTimers()
+          this.closeModal()
+        } else {
+          console.error('Error starting timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error starting timer:', error)
+      }
+    },
+    
+    async stopTimer(timerId) {
+      try {
+        const response = await axios.post('/api/timer_stop.php', {
+          action: 'stop',
+          id: timerId
+        })
+        
+        if (response.data.success) {
+          this.loadActiveTimers()
+          this.loadTasks() // Refresh tasks to update total hours
+        } else {
+          console.error('Error stopping timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error stopping timer:', error)
+      }
+    },
+    
+    formatTimerDuration(timer) {
+      if (!timer || !timer.start_time) {
+        return '00:00:00'
+      }
+      
+      // Convert UTC timestamp to local time
+      const start = new Date(timer.start_time + 'Z')
+      const now = new Date()
+      const diff = now - start
+      
+      const hours = Math.floor(diff / (1000 * 60 * 60))
+      const minutes = Math.floor((diff % (1000 * 60 * 60)) / 60000)
+      const seconds = Math.floor((diff % 60000) / 1000)
+      
+      return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+    }
+  }
+}
+</script>
+
+<style scoped>
+.header-timer {
+  display: flex;
+  align-items: center;
+}
+
+.timer-display {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  background: #f8f9fa;
+  padding: 8px 12px;
+  border-radius: 6px;
+  border: 1px solid #dee2e6;
+}
+
+.timer-info {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+
+.timer-status {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  color: #6c757d;
+}
+
+.timer-status.running {
+  color: #28a745;
+}
+
+.timer-status i {
+  font-size: 8px;
+}
+
+.timer-duration {
+  font-family: 'Courier New', monospace;
+  font-weight: bold;
+  font-size: 14px;
+  color: #495057;
+}
+
+.timer-duration-large {
+  font-family: 'Courier New', monospace;
+  font-weight: bold;
+  font-size: 18px;
+  color: #007bff;
+  margin: 4px 0;
+}
+
+.timer-task {
+  font-size: 11px;
+  color: #6c757d;
+  max-width: 150px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.timer-controls {
+  display: flex;
+  gap: 5px;
+}
+
+.no-timer {
+  display: flex;
+  align-items: center;
+}
+
+.timer-text {
+  margin-left: 5px;
+}
+
+/* Modal Styles */
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+}
+
+.modal-content {
+  background: white;
+  border-radius: 8px;
+  padding: 0;
+  max-width: 500px;
+  width: 90%;
+  max-height: 80vh;
+  overflow-y: auto;
+}
+
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20px;
+  border-bottom: 1px solid #dee2e6;
+}
+
+.modal-header h3 {
+  margin: 0;
+  color: #495057;
+}
+
+.close-btn {
+  background: none;
+  border: none;
+  font-size: 24px;
+  cursor: pointer;
+  color: #6c757d;
+}
+
+.modal-body {
+  padding: 20px;
+}
+
+.selected-task {
+  margin-bottom: 20px;
+}
+
+.selected-task h4 {
+  margin: 0;
+  color: #495057;
+}
+
+.task-selection {
+  margin-bottom: 20px;
+}
+
+.task-selection label {
+  display: block;
+  margin-bottom: 8px;
+  font-weight: 500;
+  color: #495057;
+}
+
+.form-control {
+  width: 100%;
+  padding: 8px 12px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  font-size: 14px;
+}
+
+.timer-display-large {
+  text-align: center;
+  margin: 20px 0;
+  padding: 20px;
+  background: #f8f9fa;
+  border-radius: 8px;
+}
+
+.timer-display-large .timer-status {
+  font-size: 16px;
+  margin-bottom: 10px;
+}
+
+.timer-display-large .timer-duration {
+  font-size: 24px;
+  color: #495057;
+}
+
+.timer-controls-large {
+  display: flex;
+  gap: 10px;
+  justify-content: center;
+  margin: 20px 0;
+}
+
+.no-tasks-message {
+  text-align: center;
+  padding: 20px;
+  color: #6c757d;
+}
+
+.modal-footer {
+  padding: 20px;
+  border-top: 1px solid #dee2e6;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.btn {
+  padding: 8px 16px;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.btn-sm {
+  padding: 4px 8px;
+  font-size: 12px;
+}
+
+.btn-success {
+  background: #28a745;
+  color: white;
+}
+
+.btn-danger {
+  background: #dc3545;
+  color: white;
+}
+
+.btn-info {
+  background: #17a2b8;
+  color: white;
+}
+
+.btn-primary {
+  background: #007bff;
+  color: white;
+}
+
+.btn-secondary {
+  background: #6c757d;
+  color: white;
+}
+
+.btn:hover {
+  opacity: 0.9;
+}
+</style>

+ 950 - 0
frontend/src/components/TimerManagement.vue

@@ -0,0 +1,950 @@
+<template>
+  <div class="timer-management">
+    <!-- Timer Management Button -->
+    <button @click="showManagementModal" class="btn btn-info btn-sm" title="Hallinnoi ajastimia">
+      <i class="fas fa-history"></i>
+      <span class="timer-text">Historia</span>
+    </button>
+    
+    <!-- Timer Management Modal -->
+    <div v-if="showModal" class="modal" @click.self="closeModal">
+      <div class="modal-content large">
+        <div class="modal-header">
+          <h3>Ajastimien Hallinta</h3>
+          <button @click="closeModal" class="close-btn">&times;</button>
+        </div>
+        
+        <div class="modal-body">
+          <!-- Tabs -->
+          <div class="tabs">
+            <button 
+              :class="['tab-btn', { active: activeTab === 'active' }]" 
+              @click="activeTab = 'active'"
+            >
+              Aktiiviset ({{ activeTimers.length }})
+            </button>
+            <button 
+              :class="['tab-btn', { active: activeTab === 'history' }]" 
+              @click="activeTab = 'history'"
+            >
+              Historia ({{ timerHistory.length }})
+            </button>
+            <button 
+              :class="['tab-btn', { active: activeTab === 'workhours' }]" 
+              @click="activeTab = 'workhours'"
+            >
+              Työtunnit ({{ workHours.length }})
+            </button>
+          </div>
+          
+          <!-- Active Timers Tab -->
+          <div v-if="activeTab === 'active'" class="tab-content">
+            <div v-if="loading" class="loading">Ladataan ajastimia...</div>
+            
+            <div v-else-if="activeTimers.length === 0" class="no-data">
+              <p>Ei aktiivisia ajastimia</p>
+            </div>
+            
+            <div v-else class="timer-list">
+              <div v-for="timer in activeTimers" :key="timer.id" class="timer-item active">
+                <div class="timer-info">
+                  <div class="timer-header">
+                    <h4>{{ timer.task_title || 'Tehtävä tuntematon' }}</h4>
+                    <span class="timer-status running">
+                      <i class="fas fa-circle"></i>
+                      Ajastin käynnissä
+                    </span>
+                  </div>
+                  <div class="timer-details">
+                    <p class="timer-description">{{ timer.description || '-' }}</p>
+                    <div class="timer-meta">
+                      <span><i class="fas fa-user"></i> {{ timer.first_name }} {{ timer.last_name }}</span>
+                      <span><i class="fas fa-clock"></i> {{ formatDateTime(timer.start_time) }}</span>
+                      <span class="timer-duration">{{ formatTimerDuration(timer) }}</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="timer-actions">
+                  <button @click="stopTimer(timer.id)" class="btn btn-danger btn-sm">
+                    <i class="fas fa-stop"></i>
+                    Pysäytä
+                  </button>
+                  <button @click="editTimer(timer)" class="btn btn-warning btn-sm">
+                    <i class="fas fa-edit"></i>
+                    Muokkaa
+                  </button>
+                  <button @click="deleteTimer(timer.id)" class="btn btn-danger btn-sm">
+                    <i class="fas fa-trash"></i>
+                    Poista
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+          
+          <!-- Timer History Tab -->
+          <div v-if="activeTab === 'history'" class="tab-content">
+            <div class="filter-controls">
+              <input 
+                type="date" 
+                v-model="historyFilter.date" 
+                @change="loadTimerHistory"
+                class="form-control"
+                placeholder="Päivämäärä"
+              >
+              <select v-model="historyFilter.task_id" @change="loadTimerHistory" class="form-control">
+                <option value="">Kaikki tehtävät</option>
+                <option v-for="task in tasks" :key="task.id" :value="task.id">
+                  {{ task.title }}
+                </option>
+              </select>
+              <button @click="clearHistoryFilters" class="btn btn-secondary btn-sm">
+                <i class="fas fa-times"></i>
+                Tyhjennä suodattimet
+              </button>
+            </div>
+            
+            <div v-if="loading" class="loading">Ladataan historiaa...</div>
+            
+            <div v-else-if="timerHistory.length === 0" class="no-data">
+              <p>Ei ajastinhistoriaa</p>
+            </div>
+            
+            <div v-else class="timer-list">
+              <div v-for="timer in timerHistory" :key="timer.id" class="timer-item">
+                <div class="timer-info">
+                  <div class="timer-header">
+                    <h4>{{ timer.task_title || 'Tehtävä tuntematon' }}</h4>
+                    <span class="timer-status stopped">
+                      <i class="fas fa-circle"></i>
+                      Pysäytetty
+                    </span>
+                  </div>
+                  <div class="timer-details">
+                    <p class="timer-description">{{ timer.description || '-' }}</p>
+                    <div class="timer-meta">
+                      <span><i class="fas fa-user"></i> {{ timer.first_name }} {{ timer.last_name }}</span>
+                      <span><i class="fas fa-clock"></i> {{ formatDateTime(timer.start_time) }} - {{ formatDateTime(timer.end_time) }}</span>
+                      <span class="timer-duration">{{ timer.duration || '00:00:00' }}</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="timer-actions">
+                  <button @click="viewTimerDetails(timer)" class="btn btn-info btn-sm">
+                    <i class="fas fa-eye"></i>
+                    Tiedot
+                  </button>
+                  <button @click="editTimer(timer)" class="btn btn-warning btn-sm">
+                    <i class="fas fa-edit"></i>
+                    Muokkaa
+                  </button>
+                  <button @click="deleteTimer(timer.id)" class="btn btn-danger btn-sm">
+                    <i class="fas fa-trash"></i>
+                    Poista
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+          
+          <!-- Work Hours Tab -->
+          <div v-if="activeTab === 'workhours'" class="tab-content">
+            <div class="filter-controls">
+              <input 
+                type="date" 
+                v-model="workHoursFilter.date" 
+                @change="loadWorkHours"
+                class="form-control"
+                placeholder="Päivämäärä"
+              >
+              <select v-model="workHoursFilter.task_id" @change="loadWorkHours" class="form-control">
+                <option value="">Kaikki tehtävät</option>
+                <option v-for="task in tasks" :key="task.id" :value="task.id">
+                  {{ task.title }}
+                </option>
+              </select>
+              <button @click="clearWorkHoursFilters" class="btn btn-secondary btn-sm">
+                <i class="fas fa-times"></i>
+                Tyhjennä suodattimet
+              </button>
+            </div>
+            
+            <div v-if="loading" class="loading">Ladataan tunteja...</div>
+            
+            <div v-else-if="workHours.length === 0" class="no-data">
+              <p>Ei työtunteja</p>
+            </div>
+            
+            <div v-else class="timer-list">
+              <div v-for="workHour in workHours" :key="workHour.id" class="timer-item work-hour">
+                <div class="timer-info">
+                  <div class="timer-header">
+                    <h4>{{ workHour.task_title || 'Tehtävä tuntematon' }}</h4>
+                    <span class="timer-status work-hour">
+                      <i class="fas fa-check-circle"></i>
+                      Työtunti
+                    </span>
+                  </div>
+                  <div class="timer-details">
+                    <p class="timer-description">{{ workHour.description || '-' }}</p>
+                    <div class="timer-meta">
+                      <span><i class="fas fa-calendar"></i> {{ formatDate(workHour.date) }}</span>
+                      <span><i class="fas fa-clock"></i> {{ formatTime(workHour.start_time) }} - {{ formatTime(workHour.end_time) }}</span>
+                      <span class="timer-duration">{{ workHour.hours }}h</span>
+                      <span v-if="workHour.rate" class="timer-rate">€{{ workHour.rate }}/h</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="timer-actions">
+                  <button @click="editWorkHour(workHour)" class="btn btn-warning btn-sm">
+                    <i class="fas fa-edit"></i>
+                    Muokkaa
+                  </button>
+                  <button @click="deleteWorkHour(workHour.id)" class="btn btn-danger btn-sm">
+                    <i class="fas fa-trash"></i>
+                    Poista
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        
+        <div class="modal-footer">
+          <button @click="closeModal" class="btn btn-secondary">Sulje</button>
+        </div>
+      </div>
+    </div>
+    
+    <!-- Timer Edit Modal -->
+    <div v-if="showEditModal" class="modal" @click.self="closeEditModal">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h3>Muokkaa Ajastinta</h3>
+          <button @click="closeEditModal" class="close-btn">&times;</button>
+        </div>
+        
+        <div class="modal-body">
+          <form @submit.prevent="saveTimer">
+            <div class="form-group">
+              <label for="editDescription">Kuvaus</label>
+              <textarea 
+                id="editDescription" 
+                v-model="editForm.description" 
+                rows="3"
+                class="form-control"
+                placeholder="Kuvaile ajastimen tarkoitusta"
+              ></textarea>
+            </div>
+            
+            <div class="form-group">
+              <label for="editTask">Tehtävä</label>
+              <select id="editTask" v-model="editForm.task_id" class="form-control">
+                <option value="">Valitse tehtävä...</option>
+                <option v-for="task in tasks" :key="task.id" :value="task.id">
+                  {{ task.title }}
+                </option>
+              </select>
+            </div>
+            
+            <div class="form-group">
+              <label for="editDuration">Kesto</label>
+              <div class="duration-display">
+                <span class="duration-value">{{ editForm.duration || 'Lasketaan...' }}</span>
+                <span class="duration-info">(ajastin kestänyt)</span>
+              </div>
+            </div>
+            
+            <div class="form-group">
+              <label for="editHours">Tunnit</label>
+              <input 
+                type="number" 
+                id="editHours" 
+                v-model="editForm.hours" 
+                step="0.5"
+                min="0"
+                class="form-control"
+                placeholder="Tunnit (esim. 1.5)"
+              >
+              <small class="form-text text-muted">Kokonaislaskettu tunneiksi (pyöristetty lähimpään tuntiin)</small>
+            </div>
+            
+                        
+            <div class="form-actions">
+              <button type="button" @click="closeEditModal" class="btn btn-secondary">Peruuta</button>
+              <button type="submit" class="btn btn-primary">Tallenna</button>
+            </div>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from '../api/axios'
+
+export default {
+  name: 'TimerManagement',
+  data() {
+    return {
+      showModal: false,
+      showEditModal: false,
+      activeTab: 'active',
+      loading: false,
+      activeTimers: [],
+      timerHistory: [],
+      workHours: [],
+      tasks: [],
+      historyFilter: {
+        date: '',
+        task_id: ''
+      },
+      workHoursFilter: {
+        date: '',
+        task_id: ''
+      },
+      editForm: {
+        id: null,
+        description: '',
+        task_id: ''
+      }
+    }
+  },
+  mounted() {
+    this.loadTasks()
+  },
+  methods: {
+    showManagementModal() {
+      this.showModal = true
+      this.loadAllData()
+    },
+    
+    closeModal() {
+      this.showModal = false
+    },
+    
+    closeEditModal() {
+      this.showEditModal = false
+      this.editForm = {
+        id: null,
+        description: '',
+        task_id: '',
+        duration: '',
+        hours: 0
+      }
+    },
+    
+    async loadAllData() {
+      await Promise.all([
+        this.loadActiveTimers(),
+        this.loadTimerHistory(),
+        this.loadWorkHours()
+      ])
+    },
+    
+    async loadActiveTimers() {
+      try {
+        this.loading = true
+        const response = await axios.get('/api/timers.php?action=active')
+        if (response.data.success) {
+          this.activeTimers = response.data.data || []
+        }
+      } catch (error) {
+        console.error('Error loading active timers:', error)
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    async loadTimerHistory() {
+      try {
+        this.loading = true
+        const params = new URLSearchParams()
+        
+        if (this.historyFilter.date) {
+          params.append('date', this.historyFilter.date)
+        }
+        if (this.historyFilter.task_id) {
+          params.append('task_id', this.historyFilter.task_id)
+        }
+        
+        const response = await axios.get(`/api/timer_history.php?${params}`)
+        if (response.data.success) {
+          this.timerHistory = response.data.data || []
+        }
+      } catch (error) {
+        console.error('Error loading timer history:', error)
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    async loadWorkHours() {
+      try {
+        this.loading = true
+        const params = new URLSearchParams()
+        
+        if (this.workHoursFilter.date) {
+          params.append('date', this.workHoursFilter.date)
+        }
+        if (this.workHoursFilter.task_id) {
+          params.append('task_id', this.workHoursFilter.task_id)
+        }
+        
+        const response = await axios.get(`/api/work_hours.php?${params}`)
+        if (response.data.success) {
+          this.workHours = response.data.data || []
+        }
+      } catch (error) {
+        console.error('Error loading work hours:', error)
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    async loadTasks() {
+      try {
+        const response = await axios.get('/api/tasks.php')
+        if (response.data.success) {
+          this.tasks = response.data.data || []
+        }
+      } catch (error) {
+        console.error('Error loading tasks:', error)
+      }
+    },
+    
+    async stopTimer(timerId) {
+      try {
+        const response = await axios.post('/api/timer_stop.php', {
+          action: 'stop',
+          id: timerId
+        })
+        
+        if (response.data.success) {
+          this.loadAllData()
+        } else {
+          console.error('Error stopping timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error stopping timer:', error)
+      }
+    },
+    
+    editTimer(timer) {
+      const duration = timer.duration || this.calculateDuration(timer.start_time, timer.end_time)
+      const hours = this.durationToHours(duration)
+      
+      this.editForm = {
+        id: timer.id,
+        description: timer.description || '',
+        task_id: timer.task_id,
+        duration: duration,
+        hours: hours
+      }
+      this.showEditModal = true
+    },
+    
+    async saveTimer() {
+      try {
+        const response = await axios.post('/api/timer_update.php', {
+          action: 'update',
+          id: this.editForm.id,
+          description: this.editForm.description,
+          task_id: this.editForm.task_id,
+          hours: this.editForm.hours
+        })
+        
+        if (response.data.success) {
+          this.closeEditModal()
+          this.loadAllData()
+        } else {
+          console.error('Error updating timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error updating timer:', error)
+      }
+    },
+    
+    async deleteTimer(timerId) {
+      if (!confirm('Oletko varma että haluat poistaa tämän ajastimen?')) return
+      
+      try {
+        const response = await axios.post('/api/timers.php', {
+          action: 'delete',
+          id: timerId
+        })
+        
+        if (response.data.success) {
+          this.loadAllData()
+        } else {
+          console.error('Error deleting timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error deleting timer:', error)
+      }
+    },
+    
+    viewTimerDetails(timer) {
+      // Could implement a detailed view modal
+      alert(`Ajastimen tiedot:\n\nTehtävä: ${timer.task_title}\nKuvaus: ${timer.description || '-'}\nAloitusaika: ${this.formatDateTime(timer.start_time)}\nLopetusaika: ${this.formatDateTime(timer.end_time)}\nKesto: ${timer.duration}`)
+    },
+    
+    async deleteWorkHour(workHourId) {
+      if (!confirm('Oletko varma että haluat poistaa tämän työtunnin?')) return
+      
+      try {
+        const response = await axios.delete(`/api/work_hours.php?id=${workHourId}`)
+        
+        if (response.data.success) {
+          this.loadWorkHours()
+        } else {
+          console.error('Error deleting work hour:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error deleting work hour:', error)
+      }
+    },
+    
+    editWorkHour(workHour) {
+      // Could implement work hour editing
+      console.log('Edit work hour:', workHour)
+    },
+    
+    clearHistoryFilters() {
+      this.historyFilter = {
+        date: '',
+        task_id: ''
+      }
+      this.loadTimerHistory()
+    },
+    
+    clearWorkHoursFilters() {
+      this.workHoursFilter = {
+        date: '',
+        task_id: ''
+      }
+      this.loadWorkHours()
+    },
+    
+    calculateDuration(startTime, endTime) {
+      if (!startTime) return '00:00:00'
+      
+      const start = new Date(startTime + 'Z')
+      const end = endTime ? new Date(endTime + 'Z') : new Date()
+      const diff = end - start
+      
+      const hours = Math.floor(diff / (1000 * 60 * 60))
+      const minutes = Math.floor((diff % (1000 * 60 * 60)) / 60000)
+      const seconds = Math.floor((diff % 60000) / 1000)
+      
+      return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+    },
+    
+    durationToHours(duration) {
+      if (!duration) return 0
+      
+      const parts = duration.split(':')
+      const hours = parseFloat(parts[0]) || 0
+      const minutes = parseFloat(parts[1]) || 0
+      const seconds = parseFloat(parts[2]) || 0
+      
+      // Convert to decimal hours and round to nearest hour
+      const decimalHours = hours + (minutes / 60) + (seconds / 3600)
+      return Math.round(decimalHours * 2) / 2 // Round to nearest 0.5 hour
+    },
+    
+        
+    formatTimerDuration(timer) {
+      if (!timer.start_time) return '00:00:00'
+      
+      // Convert UTC timestamp to local time
+      const start = new Date(timer.start_time + 'Z')
+      const now = timer.end_time ? new Date(timer.end_time + 'Z') : new Date()
+      const diff = now - start
+      
+      const hours = Math.floor(diff / (1000 * 60 * 60))
+      const minutes = Math.floor((diff % (1000 * 60 * 60)) / 60000)
+      const seconds = Math.floor((diff % 60000) / 1000)
+      
+      return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+    },
+    
+    formatDateTime(dateString) {
+      if (!dateString) return '-'
+      return new Date(dateString + 'Z').toLocaleString('fi-FI')
+    },
+    
+    formatDate(dateString) {
+      if (!dateString) return '-'
+      return new Date(dateString).toLocaleDateString('fi-FI')
+    },
+    
+    formatTime(timeString) {
+      if (!timeString) return '-'
+      return new Date(`1970-01-01T${timeString}Z`).toLocaleTimeString('fi-FI', { 
+        hour: '2-digit', 
+        minute: '2-digit'
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.timer-management {
+  display: inline-block;
+}
+
+.timer-text {
+  margin-left: 5px;
+}
+
+/* Modal Styles */
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background: rgba(0, 0, 0, 0.5);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+}
+
+.modal-content {
+  background: white;
+  border-radius: 8px;
+  padding: 0;
+  max-width: 900px;
+  width: 90%;
+  max-height: 80vh;
+  overflow-y: auto;
+}
+
+.modal-content.large {
+  max-width: 1200px;
+}
+
+.modal-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20px;
+  border-bottom: 1px solid #dee2e6;
+}
+
+.modal-header h3 {
+  margin: 0;
+  color: #495057;
+}
+
+.close-btn {
+  background: none;
+  border: none;
+  font-size: 24px;
+  cursor: pointer;
+  color: #6c757d;
+}
+
+.modal-body {
+  padding: 20px;
+}
+
+.modal-footer {
+  padding: 20px;
+  border-top: 1px solid #dee2e6;
+  display: flex;
+  justify-content: flex-end;
+}
+
+/* Tabs */
+.tabs {
+  display: flex;
+  border-bottom: 1px solid #dee2e6;
+  margin-bottom: 20px;
+}
+
+.tab-btn {
+  background: none;
+  border: none;
+  padding: 12px 24px;
+  cursor: pointer;
+  color: #6c757d;
+  border-bottom: 2px solid transparent;
+  transition: all 0.3s ease;
+}
+
+.tab-btn.active {
+  color: #007bff;
+  border-bottom-color: #007bff;
+}
+
+.tab-content {
+  min-height: 400px;
+}
+
+/* Filter Controls */
+.filter-controls {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+}
+
+.form-control {
+  padding: 8px 12px;
+  border: 1px solid #ced4da;
+  border-radius: 4px;
+  font-size: 14px;
+}
+
+/* Timer List */
+.timer-list {
+  display: flex;
+  flex-direction: column;
+  gap: 15px;
+}
+
+.timer-item {
+  border: 1px solid #dee2e6;
+  border-radius: 8px;
+  padding: 15px;
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 15px;
+}
+
+.timer-item.active {
+  border-left: 4px solid #28a745;
+  background: #f8fff9;
+}
+
+.timer-item.work-hour {
+  border-left: 4px solid #17a2b8;
+  background: #f8f9ff;
+}
+
+.timer-info {
+  flex: 1;
+}
+
+.timer-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.timer-header h4 {
+  margin: 0;
+  color: #495057;
+}
+
+.timer-status {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  font-size: 12px;
+  padding: 4px 8px;
+  border-radius: 12px;
+}
+
+.timer-status.running {
+  background: #d4edda;
+  color: #155724;
+}
+
+.timer-status.stopped {
+  background: #f8d7da;
+  color: #721c24;
+}
+
+.timer-status.work-hour {
+  background: #d1ecf1;
+  color: #0c5460;
+}
+
+.timer-status i {
+  font-size: 8px;
+}
+
+.timer-details {
+  display: flex;
+  flex-direction: column;
+  gap: 5px;
+}
+
+.timer-description {
+  margin: 0;
+  color: #6c757d;
+  font-size: 14px;
+}
+
+.timer-meta {
+  display: flex;
+  gap: 15px;
+  flex-wrap: wrap;
+  font-size: 12px;
+  color: #6c757d;
+}
+
+.timer-meta span {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.timer-duration {
+  font-weight: bold;
+  color: #495057 !important;
+}
+
+.timer-rate {
+  color: #28a745 !important;
+  font-weight: bold;
+}
+
+.timer-actions {
+  display: flex;
+  gap: 5px;
+  flex-shrink: 0;
+}
+
+.loading, .no-data {
+  text-align: center;
+  padding: 40px;
+  color: #6c757d;
+}
+
+/* Form Styles */
+.form-group {
+  margin-bottom: 15px;
+}
+
+.form-group label {
+  display: block;
+  margin-bottom: 5px;
+  font-weight: 500;
+  color: #495057;
+}
+
+.form-actions {
+  display: flex;
+  gap: 10px;
+  justify-content: flex-end;
+  margin-top: 20px;
+}
+
+/* Buttons */
+.btn {
+  padding: 8px 16px;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  text-decoration: none;
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  transition: opacity 0.2s ease;
+}
+
+.btn-sm {
+  padding: 4px 8px;
+  font-size: 12px;
+}
+
+.btn-primary {
+  background: #007bff;
+  color: white;
+}
+
+.btn-success {
+  background: #28a745;
+  color: white;
+}
+
+.btn-danger {
+  background: #dc3545;
+  color: white;
+}
+
+.btn-warning {
+  background: #ffc107;
+  color: #212529;
+}
+
+.modal-content {
+  max-width: 960px;
+  width: 95%;
+}
+
+/* Global modal styling for all components */
+.modal {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  background-color: rgba(0, 0, 0, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+/* Make entire application 20% wider */
+.container, .main-content, .dashboard, .app {
+  max-width: 120% !important;
+  width: 120% !important;
+}
+
+.modal-content {
+  background: white;
+  border-radius: 8px;
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+  max-height: 90vh;
+  overflow-y: auto;
+  padding: 24px;
+  margin: auto;
+}
+
+.btn-info {
+  background: #17a2b8;
+  color: white;
+}
+
+.btn-secondary {
+  background: #6c757d;
+  color: white;
+}
+
+.btn:hover {
+  opacity: 0.9;
+}
+
+.duration-display {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  background-color: #f8f9fa;
+  border: 1px solid #dee2e6;
+  border-radius: 4px;
+}
+
+.duration-value {
+  font-weight: 600;
+  color: #495057;
+  font-family: 'Courier New', monospace;
+}
+
+.duration-info {
+  font-size: 0.875rem;
+  color: #6c757d;
+}
+</style>

+ 50 - 17
frontend/src/components/common/NavigationTabs.vue

@@ -1,23 +1,33 @@
 <template>
-  <div class="nav-tabs">
-    <button 
-      :class="['nav-tab', { active: activeSection === 'items' }]" 
-      @click="$emit('section-change', 'items')"
-    >
-      Tuotteet
-    </button>
-    <button 
-      :class="['nav-tab', { active: activeSection === 'clients' }]" 
-      @click="$emit('section-change', 'clients')"
-    >
-      Asiakkaat
-    </button>
+  <div class="nav-tabs-container">
+    <div class="nav-tabs">
+      <button 
+        :class="['nav-tab', { active: activeSection === 'items' }]" 
+        @click="$emit('section-change', 'items')"
+      >
+        Tuotteet
+      </button>
+      <button 
+        :class="['nav-tab', { active: activeSection === 'clients' }]" 
+        @click="$emit('section-change', 'clients')"
+      >
+        Asiakkaat
+      </button>
+      <button 
+        :class="['nav-tab', { active: activeSection === 'projects' }]" 
+        @click="$emit('section-change', 'projects')"
+      >
+        Projektit
+      </button>
+    
+    <!-- Tasks Tab -->
     <button 
-      :class="['nav-tab', { active: activeSection === 'projects' }]" 
-      @click="$emit('section-change', 'projects')"
+      :class="['nav-tab', { active: activeSection === 'tasks' }]" 
+      @click="$emit('section-change', 'tasks')"
     >
-      Projektit
+      Tehtävät
     </button>
+    </div>
     
     <!-- Talous Dropdown Menu -->
     <div class="nav-dropdown" @click="toggleDropdown">
@@ -48,12 +58,22 @@
         </button>
       </div>
     </div>
+    
+    <!-- Timer Component -->
+    <div class="timer-section">
+      <HeaderTimer @navigate-to-tasks="$emit('section-change', 'tasks')" />
+    </div>
   </div>
 </template>
 
 <script>
+import HeaderTimer from '../HeaderTimer.vue'
+
 export default {
   name: 'NavigationTabs',
+  components: {
+    HeaderTimer
+  },
   props: {
     activeSection: {
       type: String,
@@ -94,12 +114,25 @@ export default {
 </script>
 
 <style scoped>
-.nav-tabs {
+.nav-tabs-container {
   display: flex;
+  justify-content: space-between;
+  align-items: center;
   border-bottom: 2px solid #e9ecef;
   margin-bottom: 20px;
 }
 
+.nav-tabs {
+  display: flex;
+  align-items: center;
+}
+
+.timer-section {
+  display: flex;
+  align-items: center;
+  margin-left: 20px;
+}
+
 .nav-tab {
   background: none;
   border: none;

+ 158 - 4
frontend/src/components/projects/TaskManagementSection.vue

@@ -50,6 +50,10 @@
               <i class="fas fa-clock"></i>
               Työtunnit
             </button>
+            <button @click="toggleTimer(task)" class="btn btn-small" :class="hasActiveTimer(task.id) ? 'btn-danger' : 'btn-success'">
+              <i class="fas" :class="hasActiveTimer(task.id) ? 'fa-stop' : 'fa-stopwatch'"></i>
+              {{ hasActiveTimer(task.id) ? 'Pysäytä' : 'Aloita ajastin' }}
+            </button>
             <button @click="editTask(task)" class="btn btn-small">
               <i class="fas fa-edit"></i>
               Muokkaa
@@ -259,9 +263,6 @@
                   Käytä asiakkaan hintaa (€{{ selectedTask.client_hour_price }})
                 </button>
               </div>
-              <div v-if="selectedTask?.client_hour_price" class="client-rate-info">
-                <small>Asiakkaan tuntihinta: €{{ selectedTask.client_hour_price }}</small>
-              </div>
             </div>
             
             <div class="form-group">
@@ -286,6 +287,41 @@
         </div>
       </div>
     </div>
+    
+    <!-- Timer Modal -->
+    <div v-if="showTimerModal" class="modal">
+      <div class="modal-content">
+        <div class="modal-header">
+          <h3>Ajastin: {{ selectedTask?.title }}</h3>
+          <button @click="closeTimerModal" class="close-btn">&times;</button>
+        </div>
+        
+        <div class="modal-body">
+          <div class="timer-display" v-if="activeTimers.length > 0">
+            <div class="timer-info">
+              <div class="timer-status" :class="{ 'timer-running': activeTimers[0]?.end_time === null }">
+                <i class="fas fa-circle"></i>
+                {{ activeTimers[0]?.end_time === null ? 'Ajastin käynnissä' : 'Ajastin pysäytetty' }}
+              </div>
+              <div class="timer-duration">
+                {{ formatTimerDuration(activeTimers[0]) }}
+              </div>
+            </div>
+          </div>
+          
+          <div class="timer-controls">
+            <button @click="startTimer" class="btn btn-success">
+              <i class="fas fa-play"></i>
+              Aloita
+            </button>
+            <button @click="stopTimer(activeTimers[0]?.id)" class="btn btn-danger" v-if="activeTimers[0]?.end_time === null">
+              <i class="fas fa-stop"></i>
+              Pysäytä
+            </button>
+          </div>
+        </div>
+      </div>
+    </div>
   </div>
 </template>
 
@@ -328,6 +364,26 @@ export default {
         hours: '',
         rate: '',
         description: ''
+      },
+      // Timer properties
+      showTimerModal: false,
+      timerForm: {
+        id: null,
+        task_id: null,
+        user_id: 1,
+        start_time: '',
+        end_time: '',
+        duration: '',
+        description: ''
+      },
+      activeTimers: [],
+      timerDisplayProperties: {
+        timerRunning: false,
+        timerDuration: ''
+      },
+      timerModalProperties: {
+        timerRunning: false,
+        timerDuration: ''
       }
     }
   },
@@ -335,6 +391,7 @@ export default {
   mounted() {
     this.loadTasks()
     this.loadProjects()
+    this.loadActiveTimers()
   },
   
   methods: {
@@ -367,12 +424,26 @@ export default {
             task.total_hours = 0
           }
         } catch (error) {
-          console.error('Error loading work hours for task:', task.id, error)
+          console.error('Error loading work hours for Task:', task.id, error)
           task.total_hours = 0
         }
       }
     },
     
+    async loadActiveTimers() {
+      try {
+        const response = await axios.get('/api/timers.php?action=active')
+        if (response.data.success) {
+          this.activeTimers = response.data.data
+        } else {
+          this.activeTimers = []
+        }
+      } catch (error) {
+        console.error('Error loading active timers:', error)
+        this.activeTimers = []
+      }
+    },
+    
     async loadProjects() {
       try {
         const response = await axios.get('/api/projects.php')
@@ -530,6 +601,89 @@ export default {
       this.showWorkHourModal = true
     },
     
+    showTimer(task) {
+      this.selectedTask = task
+      this.loadActiveTimers()
+      this.showTimerModal = true
+    },
+    
+    closeTimerModal() {
+      this.showTimerModal = false
+      this.selectedTask = null
+      this.activeTimers = []
+    },
+    
+    async startTimer() {
+      try {
+        const response = await axios.post('/api/timers.php', {
+          action: 'start',
+          task_id: this.selectedTask.id,
+          user_id: 1, // Default to current user
+          description: `Timer started for ${this.selectedTask.title}`
+        })
+        
+        if (response.data.success) {
+          this.loadActiveTimers()
+          this.closeTimerModal()
+        } else {
+          console.error('Error starting timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error starting timer:', error)
+      }
+    },
+    
+    async stopTimer(timerId) {
+      try {
+        const response = await axios.post('/api/timers.php', {
+          action: 'stop',
+          id: timerId
+        })
+        
+        if (response.data.success) {
+          this.loadActiveTimers()
+          this.loadTasks() // Refresh tasks to update total hours
+        } else {
+          console.error('Error stopping timer:', response.data.message)
+        }
+      } catch (error) {
+        console.error('Error stopping timer:', error)
+      }
+    },
+    
+    async toggleTimer(task) {
+      if (this.hasActiveTimer(task.id)) {
+        // Stop the timer
+        const activeTimer = this.activeTimers.find(timer => timer.task_id === task.id)
+        if (activeTimer) {
+          await this.stopTimer(activeTimer.id)
+        }
+      } else {
+        // Start the timer
+        await this.startTimer(task)
+      }
+    },
+    
+    hasActiveTimer(taskId) {
+      return this.activeTimers.some(timer => 
+        timer.task_id === parseInt(taskId) && timer.end_time === null
+      )
+    },
+    
+    formatTimerDuration(timer) {
+      if (!timer.start_time) return '00:00:00'
+      
+      const start = new Date(timer.start_time)
+      const now = new Date()
+      const diff = now - start
+      
+      const hours = Math.floor(diff / (1000 * 60 * 60))
+      const minutes = Math.floor((diff % (1000 * 60 * 60)) / 60000)
+      const seconds = Math.floor((diff % 60000) / 1000)
+      
+      return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+    },
+    
     editWorkHour(workHour) {
       this.isEditingWorkHour = true
       this.workHourForm = { ...workHour }

+ 1 - 2
frontend/vite.config.js

@@ -8,8 +8,7 @@ export default defineConfig({
     proxy: {
       '/api': {
         target: process.env.VUE_APP_API_URL?.replace(/\/$/, '') || 'http://localhost:8080',
-        changeOrigin: true,
-        rewrite: (path) => path.replace(/^\/api/, '')
+        changeOrigin: true
       }
     }
   }

+ 24 - 0
test_timer_stop.php

@@ -0,0 +1,24 @@
+<?php
+require_once 'backend/models/Timer.php';
+require_once 'backend/config/database.php';
+
+$database = new Database();
+$timer = new Timer($database->getConnection());
+
+$timer->id = 3;
+echo "Attempting to stop timer...\n";
+
+try {
+    $timer->end_time = date('Y-m-d H:i:s');
+    $query = "UPDATE timers SET end_time = NOW(), updated_at = NOW() WHERE id = ? AND end_time IS NULL";
+    $stmt = $timer->conn->prepare($query);
+    $stmt->execute([$timer->id]);
+    
+    if ($stmt->rowCount() > 0) {
+        echo json_encode(['success' => true, 'message' => 'Timer stopped successfully']);
+    } else {
+        echo json_encode(['success' => false, 'message' => 'Timer not found']);
+    }
+} catch (Exception $e) {
+    echo json_encode(['error' => 'Failed to stop timer: ' . $e->getMessage()]);
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff