diff --git a/db/migrations/20260215_create_clients_table.sql b/db/migrations/20260215_create_clients_table.sql new file mode 100644 index 0000000..0f06e57 --- /dev/null +++ b/db/migrations/20260215_create_clients_table.sql @@ -0,0 +1,15 @@ +-- Create clients table +CREATE TABLE IF NOT EXISTS clients ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + phone VARCHAR(50), + address TEXT, + city VARCHAR(100), + province_state VARCHAR(100), + postal_code VARCHAR(20), + country VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/employees.php b/employees.php index 7346ac0..4669a9e 100644 --- a/employees.php +++ b/employees.php @@ -12,24 +12,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) { $position = $_POST['position'] ?? ''; $start_date = $_POST['start_date'] ?? date('Y-m-d'); $is_limited = isset($_POST['is_limited']) ? 1 : 0; + $phone = $_POST['phone'] ?? ''; + $password = $_POST['password'] ?? ''; + $force_password_change = isset($_POST['force_password_change']) ? 1 : 0; $initial_wage = (float)($_POST['initial_wage'] ?? 0); $team_ids = $_POST['teams'] ?? []; if ($first_name && $last_name) { $user_id = null; if (!$is_limited && $email) { - $stmt = db()->prepare("INSERT IGNORE INTO users (tenant_id, name, email, role) VALUES (?, ?, ?, 'staff')"); - $stmt->execute([$tenant_id, "$first_name $last_name", $email]); - $user_id = (int)db()->lastInsertId(); - if ($user_id === 0) { - $stmt = db()->prepare("SELECT id FROM users WHERE email = ?"); - $stmt->execute([$email]); - $user_id = (int)($stmt->fetchColumn() ?: null); - } + $hashed_password = $password ? password_hash($password, PASSWORD_DEFAULT) : null; + $stmt = db()->prepare("INSERT INTO users (tenant_id, name, email, phone, password, require_password_change, role) + VALUES (?, ?, ?, ?, ?, ?, 'staff') + ON DUPLICATE KEY UPDATE + phone = VALUES(phone), + password = COALESCE(VALUES(password), password), + require_password_change = VALUES(require_password_change)"); + $stmt->execute([$tenant_id, "$first_name $last_name", $email, $phone, $hashed_password, $force_password_change]); + + $stmt = db()->prepare("SELECT id FROM users WHERE email = ? AND tenant_id = ?"); + $stmt->execute([$email, $tenant_id]); + $user_id = (int)($stmt->fetchColumn() ?: null); } - $stmt = db()->prepare("INSERT INTO employees (tenant_id, first_name, last_name, email, position, start_date, is_limited, user_id, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); - $stmt->execute([$tenant_id, $first_name, $last_name, $email, $position, $start_date, $is_limited, $user_id, "$first_name $last_name"]); + $stmt = db()->prepare("INSERT INTO employees (tenant_id, first_name, last_name, email, phone, position, start_date, is_limited, user_id, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); + $stmt->execute([$tenant_id, $first_name, $last_name, $email, $phone, $position, $start_date, $is_limited, $user_id, "$first_name $last_name"]); $employee_id = (int)db()->lastInsertId(); if ($initial_wage > 0) { @@ -53,6 +60,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) { } // Fetch Data +$stmt = db()->prepare("SELECT pref_key, pref_value FROM system_preferences WHERE tenant_id = ?"); +$stmt->execute([$tenant_id]); +$prefs = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + $employees = db()->prepare(" SELECT e.*, (SELECT hourly_rate FROM employee_wages WHERE employee_id = e.id ORDER BY effective_date DESC LIMIT 1) as current_wage @@ -156,6 +167,10 @@ include __DIR__ . '/includes/header.php'; +
+ + +
@@ -179,12 +194,34 @@ include __DIR__ . '/includes/header.php';
-
-
- + +
+
+
+ +
+ + diff --git a/import_clients.php b/import_clients.php new file mode 100644 index 0000000..5c851c3 --- /dev/null +++ b/import_clients.php @@ -0,0 +1,158 @@ +beginTransaction(); + try { + $stmt = db()->prepare("INSERT INTO clients (tenant_id, name, email, phone, address, city, province_state, postal_code, country) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"); + + while (($row = fgetcsv($handle)) !== false) { + $rowCount++; + $data = []; + foreach ($expected as $col) { + $data[$col] = isset($columnMap[$col]) && isset($row[$columnMap[$col]]) ? trim($row[$columnMap[$col]]) : ''; + } + + if (empty($data['name'])) { + $skippedCount++; + $results[] = "Row $rowCount: Skipped (Missing Name)"; + continue; + } + + // Simple validation: email + if (!empty($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) { + $results[] = "Row $rowCount: Warning (Invalid Email: " . htmlspecialchars($data['email']) . ")"; + } + + $stmt->execute([ + 1, // Hardcoded tenant_id for now as per app convention + $data['name'], + $data['email'], + $data['phone'], + $data['address'], + $data['city'], + $data['province_state'], + $data['postal_code'], + $data['country'] + ]); + $importCount++; + } + db()->commit(); + $message = "Import completed successfully. $importCount clients imported, $skippedCount skipped."; + } catch (Exception $e) { + db()->rollBack(); + $error = 'Database error: ' . $e->getMessage(); + } + } + fclose($handle); + } +} + +include 'includes/header.php'; +?> + +
+
+

Import Clients

+ + Download Template + +
+ + + + + + + + + +
+
+
+
+
+
+ + +
+ Max file size: 2MB. Only .csv files are allowed. +
+
+ +
+
+
+
+
+
+
+
Import Instructions
+
+
+
    +
  1. Download the CSV template using the button above.
  2. +
  3. Fill in your client data, ensuring the name field is not empty.
  4. +
  5. Save the file as a CSV (Comma Separated Values).
  6. +
  7. Upload the file using the form on the left.
  8. +
  9. Review any warnings or errors that appear after processing.
  10. +
+
+ Existing clients with the same name will be added as new entries. +
+
+
+
+
+ + +
+
+
Import Logs
+
+
+
+ +
+ +
+ +
+
+
+ +
+ + diff --git a/import_labour.php b/import_labour.php new file mode 100644 index 0000000..a8f6892 --- /dev/null +++ b/import_labour.php @@ -0,0 +1,185 @@ +query("SELECT id, code FROM projects WHERE tenant_id = 1"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $projects[$row['code']] = $row['id']; + + $employees = []; + $stmt = db()->query("SELECT id, email FROM employees WHERE tenant_id = 1"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $employees[$row['email']] = $row['id']; + + $labourTypes = []; + $stmt = db()->query("SELECT id, name FROM labour_types"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $labourTypes[strtolower($row['name'])] = $row['id']; + + $evidenceTypes = []; + $stmt = db()->query("SELECT id, name FROM evidence_types"); + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) $evidenceTypes[strtolower($row['name'])] = $row['id']; + + $rowCount = 0; + $importCount = 0; + $skippedCount = 0; + + db()->beginTransaction(); + try { + $stmt = db()->prepare("INSERT INTO labour_entries (tenant_id, project_id, employee_id, entry_date, hours, labour_type_id, evidence_type_id, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"); + + while (($row = fgetcsv($handle)) !== false) { + $rowCount++; + $data = []; + foreach ($expected as $col) { + $data[$col] = isset($columnMap[$col]) && isset($row[$columnMap[$col]]) ? trim($row[$columnMap[$col]]) : ''; + } + + $rowErrors = []; + + $projectId = $projects[$data['project_code']] ?? null; + $employeeId = $employees[$data['employee_email']] ?? null; + $labourTypeId = $labourTypes[strtolower($data['labour_type'])] ?? null; + $evidenceTypeId = $evidenceTypes[strtolower($data['evidence_type'])] ?? 5; // Default to 'None' + + if (!$projectId) $rowErrors[] = "Invalid Project Code: " . $data['project_code']; + if (!$employeeId) $rowErrors[] = "Invalid Employee Email: " . $data['employee_email']; + if (!$labourTypeId) $rowErrors[] = "Invalid Labour Type: " . $data['labour_type']; + if (empty($data['date']) || !strtotime($data['date'])) $rowErrors[] = "Invalid Date: " . $data['date']; + if (!is_numeric($data['hours'])) $rowErrors[] = "Invalid Hours: " . $data['hours']; + + if (!empty($rowErrors)) { + $skippedCount++; + $results[] = "Row $rowCount: Skipped (" . implode(', ', $rowErrors) . ")"; + continue; + } + + $stmt->execute([ + 1, + $projectId, + $employeeId, + date('Y-m-d', strtotime($data['date'])), + $data['hours'], + $labourTypeId, + $evidenceTypeId, + $data['notes'] + ]); + $importCount++; + } + db()->commit(); + $message = "Import completed successfully. $importCount activities imported, $skippedCount skipped."; + } catch (Exception $e) { + db()->rollBack(); + $error = 'Database error: ' . $e->getMessage(); + } + } + fclose($handle); + } +} + +include 'includes/header.php'; +?> + +
+
+

Import Labour Activities

+ + Download Template + +
+ + + + + + + + + +
+
+
+
+
+
+ + +
+ Max file size: 5MB. Only .csv files are allowed. +
+
+ +
+
+
+
+
+
+
+
Import Instructions
+
+
+
    +
  1. Download the CSV template using the button above.
  2. +
  3. Ensure Project Code and Employee Email match existing records.
  4. +
  5. Date should be in YYYY-MM-DD format.
  6. +
  7. Labour Type must match one of: +
      +
    • Experimental Development
    • +
    • Technical Support
    • +
    • Technical Planning
    • +
    +
  8. +
  9. Upload and review the logs for any skipped rows.
  10. +
+
+
+
+
+ + +
+
+
Import Logs
+
+
+
+ +
+ +
+ +
+
+
+ +
+ + diff --git a/includes/header.php b/includes/header.php index fc0107d..8fb1562 100644 --- a/includes/header.php +++ b/includes/header.php @@ -66,8 +66,17 @@ $currentPage = basename($_SERVER['PHP_SELF']);
  • Files
  • -
    diff --git a/samples/clients_template.csv b/samples/clients_template.csv new file mode 100644 index 0000000..90a61ef --- /dev/null +++ b/samples/clients_template.csv @@ -0,0 +1,3 @@ +name,email,phone,address,city,province_state,postal_code,country +Acme Corp,contact@acme.com,555-0123,123 Industrial Way,Tech City,ON,M5V 2N2,Canada +Global Tech,info@globaltech.io,555-9876,456 Innovation Blvd,Futureville,QC,H3B 1A1,Canada diff --git a/samples/labour_template.csv b/samples/labour_template.csv new file mode 100644 index 0000000..04fdd07 --- /dev/null +++ b/samples/labour_template.csv @@ -0,0 +1,4 @@ +project_code,employee_email,date,hours,labour_type,evidence_type,notes +PROJ-001,john.doe@example.com,2026-02-01,7.5,Experimental Development,Logbooks,Analysis of component A +PROJ-001,jane.smith@example.com,2026-02-01,4.0,Technical Support,Test Results,Testing component B +PROJ-002,john.doe@example.com,2026-02-02,6.0,Technical Planning,Design Documents,Drafting architecture diff --git a/settings.php b/settings.php index 4f6eb12..a2a7f3e 100644 --- a/settings.php +++ b/settings.php @@ -230,6 +230,28 @@ include __DIR__ . '/includes/header.php';
    + + +
    +
    +
    + Data Management & Imports +
    +
    +

    Import legacy data or bulk records from other systems using CSV templates.

    + +
    +
    +
    diff --git a/system_preferences.php b/system_preferences.php new file mode 100644 index 0000000..da18782 --- /dev/null +++ b/system_preferences.php @@ -0,0 +1,113 @@ + $_POST['pwd_min_length'] ?? '8', + 'pwd_require_upper' => isset($_POST['pwd_require_upper']) ? '1' : '0', + 'pwd_require_lower' => isset($_POST['pwd_require_lower']) ? '1' : '0', + 'pwd_require_numbers' => isset($_POST['pwd_require_numbers']) ? '1' : '0', + 'pwd_no_common_words' => isset($_POST['pwd_no_common_words']) ? '1' : '0' + ]; + + foreach ($prefs as $key => $val) { + $stmt = db()->prepare("INSERT INTO system_preferences (tenant_id, pref_key, pref_value) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE pref_value = VALUES(pref_value)"); + $stmt->execute([$tenant_id, $key, $val]); + } + + $stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)"); + $stmt->execute([$tenant_id, 'Settings Updated', 'Updated system preferences and password requirements']); + + header("Location: system_preferences.php?success=1"); + exit; +} + +// Fetch current preferences +$stmt = db()->prepare("SELECT pref_key, pref_value FROM system_preferences WHERE tenant_id = ?"); +$stmt->execute([$tenant_id]); +$prefs = $stmt->fetchAll(PDO::FETCH_KEY_PAIR); + +$pageTitle = "SR&ED Manager - System Preferences"; +include __DIR__ . '/includes/header.php'; +?> + +
    +
    +
    +
    +

    System Preferences

    + + Preferences saved successfully + +
    + +
    +
    +
    +
    Password Requirements
    +
    +
    +
    +
    + + +
    Passwords shorter than this will be rejected.
    +
    +
    + +
    +
    +
    + > + +
    +
    +
    +
    + > + +
    +
    +
    +
    + > + +
    +
    +
    +
    + > + +
    +
    +
    +
    +
    + +
    +
    +
    Authentication & 2FA
    +
    +
    +

    2FA settings are currently managed per-user in the Employee management section. Telephone numbers provided there will be used for SMS-based verification factors.

    +
    +
    + +
    + Cancel + +
    +
    +
    +
    +
    + + diff --git a/uploads/6991f572867cb.png b/uploads/6991f572867cb.png new file mode 100644 index 0000000..ad2d2cd Binary files /dev/null and b/uploads/6991f572867cb.png differ diff --git a/uploads/thumb_6991f572867cb.png b/uploads/thumb_6991f572867cb.png new file mode 100644 index 0000000..2073d98 Binary files /dev/null and b/uploads/thumb_6991f572867cb.png differ