Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ff74420bd | ||
|
|
36b8744143 | ||
|
|
c8a94f29c5 | ||
|
|
a2a8ca4262 | ||
|
|
5ba9886549 | ||
|
|
0f6b05982a | ||
|
|
cc5d6146bf | ||
|
|
b56a9a8592 | ||
|
|
037f8c27be | ||
|
|
353a0f1f3b | ||
|
|
cb637ac2fc | ||
|
|
33ad9419e5 | ||
|
|
87b38e5dfa | ||
|
|
2c1612942a | ||
|
|
bb0b464262 | ||
|
|
444cd73cc6 | ||
|
|
10ea0441d5 | ||
|
|
f03a7a8de5 | ||
|
|
a2a711f887 |
57
api/export_expenses.php
Normal file
57
api/export_expenses.php
Normal file
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$tenant_id = 1;
|
||||
|
||||
$filter_project = (int)($_GET['project_id'] ?? 0);
|
||||
$filter_supplier = (int)($_GET['supplier_id'] ?? 0);
|
||||
$filter_start = $_GET['start_date'] ?? '';
|
||||
$filter_end = $_GET['end_date'] ?? '';
|
||||
|
||||
$where = ["e.tenant_id = ?"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if ($filter_project) {
|
||||
$where[] = "e.project_id = ?";
|
||||
$params[] = $filter_project;
|
||||
}
|
||||
if ($filter_supplier) {
|
||||
$where[] = "e.supplier_id = ?";
|
||||
$params[] = $filter_supplier;
|
||||
}
|
||||
if ($filter_start) {
|
||||
$where[] = "e.entry_date >= ?";
|
||||
$params[] = $filter_start;
|
||||
}
|
||||
if ($filter_end) {
|
||||
$where[] = "e.entry_date <= ?";
|
||||
$params[] = $filter_end;
|
||||
}
|
||||
|
||||
$where_clause = implode(" AND ", $where);
|
||||
|
||||
$stmt = db()->prepare("
|
||||
SELECT e.entry_date, s.name as supplier_name, p.name as project_name, e.amount, e.allocation_percent, et.name as expense_type, e.notes
|
||||
FROM expenses e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
JOIN suppliers s ON e.supplier_id = s.id
|
||||
LEFT JOIN expense_types et ON e.expense_type_id = et.id
|
||||
WHERE $where_clause
|
||||
ORDER BY e.entry_date DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$filename_suffix = date('Y-m-d_H-i-s');
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename=expenses_export_' . $filename_suffix . '.csv');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
fputcsv($output, ['Date', 'Supplier', 'Project', 'Amount', 'Allocation %', 'Expense Type', 'Notes']);
|
||||
|
||||
foreach ($data as $row) {
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
fclose($output);
|
||||
exit;
|
||||
79
api/export_labour.php
Normal file
79
api/export_labour.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$tenant_id = 1;
|
||||
|
||||
$filter_project = $_GET['project_id'] ?? null;
|
||||
$filter_projects = $_GET['projects'] ?? []; // Array from reports.php
|
||||
$filter_employee = (int)($_GET['employee_id'] ?? 0);
|
||||
$filter_type = (int)($_GET['labour_type_id'] ?? ($_GET['activity_type_id'] ?? 0));
|
||||
$filter_evidence = (int)($_GET['evidence_type_id'] ?? 0);
|
||||
$filter_start = $_GET['start_date'] ?? '';
|
||||
$filter_end = $_GET['end_date'] ?? '';
|
||||
$filter_team = (int)($_GET['team_id'] ?? 0);
|
||||
|
||||
$where = ["le.tenant_id = ?"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if ($filter_project) {
|
||||
$where[] = "le.project_id = ?";
|
||||
$params[] = (int)$filter_project;
|
||||
}
|
||||
if (!empty($filter_projects)) {
|
||||
$placeholders = implode(',', array_fill(0, count($filter_projects), '?'));
|
||||
$where[] = "le.project_id IN ($placeholders)";
|
||||
foreach ($filter_projects as $pid) $params[] = (int)$pid;
|
||||
}
|
||||
if ($filter_employee) {
|
||||
$where[] = "le.employee_id = ?";
|
||||
$params[] = $filter_employee;
|
||||
}
|
||||
if ($filter_team) {
|
||||
$where[] = "le.employee_id IN (SELECT employee_id FROM employee_teams WHERE team_id = ?)";
|
||||
$params[] = $filter_team;
|
||||
}
|
||||
if ($filter_type) {
|
||||
$where[] = "le.labour_type_id = ?";
|
||||
$params[] = $filter_type;
|
||||
}
|
||||
if ($filter_evidence) {
|
||||
$where[] = "le.evidence_type_id = ?";
|
||||
$params[] = $filter_evidence;
|
||||
}
|
||||
if ($filter_start) {
|
||||
$where[] = "le.entry_date >= ?";
|
||||
$params[] = $filter_start;
|
||||
}
|
||||
if ($filter_end) {
|
||||
$where[] = "le.entry_date <= ?";
|
||||
$params[] = $filter_end;
|
||||
}
|
||||
|
||||
$where_clause = implode(" AND ", $where);
|
||||
|
||||
$stmt = db()->prepare("
|
||||
SELECT le.entry_date, e.name as employee_name, p.name as project_name, le.hours, lt.name as labour_type, et.name as evidence_type, le.notes
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN labour_types lt ON le.labour_type_id = lt.id
|
||||
LEFT JOIN evidence_types et ON le.evidence_type_id = et.id
|
||||
WHERE $where_clause
|
||||
ORDER BY le.entry_date DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$data = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$filename_suffix = date('Y-m-d_H-i-s');
|
||||
header('Content-Type: text/csv; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename=labour_export_' . $filename_suffix . '.csv');
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
fputcsv($output, ['Date', 'Employee', 'Project', 'Hours', 'Labour Type', 'Evidence Type', 'Notes']);
|
||||
|
||||
foreach ($data as $row) {
|
||||
fputcsv($output, $row);
|
||||
}
|
||||
fclose($output);
|
||||
exit;
|
||||
26
api/get_labour_stats.php
Normal file
26
api/get_labour_stats.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$employee_id = (int)($_GET['employee_id'] ?? 0);
|
||||
$start_date = $_GET['start_date'] ?? '';
|
||||
$end_date = $_GET['end_date'] ?? '';
|
||||
|
||||
if (!$employee_id || !$start_date || !$end_date) {
|
||||
echo json_encode([]);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = db();
|
||||
$stmt = $db->prepare("
|
||||
SELECT entry_date, SUM(hours) as total_hours
|
||||
FROM labour_entries
|
||||
WHERE employee_id = ? AND entry_date BETWEEN ? AND ?
|
||||
GROUP BY entry_date
|
||||
");
|
||||
$stmt->execute([$employee_id, $start_date, $end_date]);
|
||||
echo json_encode($stmt->fetchAll());
|
||||
} catch (Exception $e) {
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
49
api/search.php
Normal file
49
api/search.php
Normal file
@ -0,0 +1,49 @@
|
||||
<?php
|
||||
header('Content-Type: application/json');
|
||||
require_once __DIR__ . '/../db/config.php';
|
||||
|
||||
$q = $_GET['q'] ?? '';
|
||||
|
||||
if (strlen($q) < 2) {
|
||||
echo json_encode([]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
try {
|
||||
$db = db();
|
||||
|
||||
// Search Projects
|
||||
$stmt = $db->prepare("SELECT id, name, code, is_archived FROM projects WHERE name LIKE ? OR code LIKE ? LIMIT 5");
|
||||
$stmt->execute(['%' . $q . '%', '%' . $q . '%']);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$label = $row['name'] . ' (' . $row['code'] . ')';
|
||||
if ($row['is_archived']) {
|
||||
$label .= ' [ARCHIVED]';
|
||||
}
|
||||
$results[] = [
|
||||
'type' => 'Project',
|
||||
'id' => $row['id'],
|
||||
'label' => $label,
|
||||
'url' => 'project_detail.php?id=' . $row['id']
|
||||
];
|
||||
}
|
||||
|
||||
// Search Employees
|
||||
$stmt = $db->prepare("SELECT id, name, position FROM employees WHERE name LIKE ? OR first_name LIKE ? OR last_name LIKE ? LIMIT 5");
|
||||
$stmt->execute(['%' . $q . '%', '%' . $q . '%', '%' . $q . '%']);
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$results[] = [
|
||||
'type' => 'Employee',
|
||||
'id' => $row['id'],
|
||||
'label' => $row['name'] . ' - ' . ($row['position'] ?? 'Staff'),
|
||||
'url' => 'employee_detail.php?id=' . $row['id']
|
||||
];
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
// Silence error for now
|
||||
}
|
||||
|
||||
echo json_encode($results);
|
||||
252
assets/css/custom.css
Normal file
252
assets/css/custom.css
Normal file
@ -0,0 +1,252 @@
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: #0f172a;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 1rem 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
color: #ffffff !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus,
|
||||
.btn-primary:active,
|
||||
.btn-primary.active,
|
||||
.btn-primary:focus-visible {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #ffffff !important;
|
||||
box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link.active {
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
}
|
||||
|
||||
.nav-pills .nav-link.active,
|
||||
.nav-pills .nav-link.active:hover,
|
||||
.nav-pills .nav-link.active:focus,
|
||||
.nav-pills .nav-link.active:active {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
border-bottom: none !important;
|
||||
box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.btn-check:checked + .btn-outline-primary,
|
||||
.btn-check:active + .btn-outline-primary,
|
||||
.btn-outline-primary:active,
|
||||
.btn-outline-primary.active,
|
||||
.btn-outline-primary.dropdown-toggle.show {
|
||||
color: #ffffff !important;
|
||||
background-color: #3b82f6 !important;
|
||||
border-color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background-color: #f8fafc;
|
||||
color: #64748b;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 1rem 1.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-active { background-color: #dcfce7; color: #166534; }
|
||||
.status-on-hold { background-color: #fef9c3; color: #854d0e; }
|
||||
.status-completed { background-color: #dbeafe; color: #1e40af; }
|
||||
|
||||
.activity-feed {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Filter Sidebar Styles */
|
||||
.filter-sidebar {
|
||||
transition: all 0.3s ease;
|
||||
width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-sidebar.collapsed {
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
position: absolute;
|
||||
left: -40px;
|
||||
top: 10px;
|
||||
z-index: 10;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px 0 0 4px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.filter-card {
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.extra-small {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Calendar Styles */
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background-color: #e2e8f0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
background-color: #ffffff;
|
||||
min-height: 100px;
|
||||
padding: 0.75rem;
|
||||
position: relative;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.calendar-day:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.calendar-header-day {
|
||||
background-color: #f1f5f9;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.day-hours {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
right: 0.75rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.other-month {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.other-month .day-number {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
BIN
assets/images/logo/company_logo_1771177204.png
Normal file
BIN
assets/images/logo/company_logo_1771177204.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
assets/pasted-20260215-020503-9ea6b62b.png
Normal file
BIN
assets/pasted-20260215-020503-9ea6b62b.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
266
company_settings.php
Normal file
266
company_settings.php
Normal file
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
/**
|
||||
* Company Preferences - Identity & Settings
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$success = false;
|
||||
$error = '';
|
||||
|
||||
// Canadian Provinces
|
||||
$provinces = [
|
||||
'AB' => 'Alberta', 'BC' => 'British Columbia', 'MB' => 'Manitoba',
|
||||
'NB' => 'New Brunswick', 'NL' => 'Newfoundland and Labrador', 'NS' => 'Nova Scotia',
|
||||
'ON' => 'Ontario', 'PE' => 'Prince Edward Island', 'QC' => 'Quebec',
|
||||
'SK' => 'Saskatchewan', 'NT' => 'Northwest Territories', 'NU' => 'Nunavut', 'YT' => 'Yukon'
|
||||
];
|
||||
|
||||
// Business Sectors
|
||||
$sectors = [
|
||||
'Telecommunications', 'Information Technology', 'Professional Services',
|
||||
'Manufacturing', 'Construction', 'Retail', 'Healthcare', 'Energy', 'Other'
|
||||
];
|
||||
|
||||
// Handle Form Submission
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$company_name = $_POST['company_name'] ?? '';
|
||||
$address_1 = $_POST['address_1'] ?? '';
|
||||
$address_2 = $_POST['address_2'] ?? '';
|
||||
$city = $_POST['city'] ?? '';
|
||||
$province = $_POST['province'] ?? '';
|
||||
$postal_code = $_POST['postal_code'] ?? '';
|
||||
$phone = $_POST['phone'] ?? '';
|
||||
$phone_2 = $_POST['phone_2'] ?? '';
|
||||
$email = $_POST['email'] ?? '';
|
||||
$website = $_POST['website'] ?? '';
|
||||
$fiscal_year_end = $_POST['fiscal_year_end'] ?: null;
|
||||
$business_number = $_POST['business_number'] ?? '';
|
||||
$timezone = $_POST['timezone'] ?? '';
|
||||
$sector = $_POST['sector'] ?? '';
|
||||
$notifications_enabled = isset($_POST['notifications_enabled']) ? 1 : 0;
|
||||
|
||||
// Handle Logo Upload
|
||||
$logo_path = $_POST['current_logo'] ?? '';
|
||||
if (isset($_FILES['logo']) && $_FILES['logo']['error'] === UPLOAD_ERR_OK) {
|
||||
$upload_dir = __DIR__ . '/assets/images/logo/';
|
||||
if (!is_dir($upload_dir)) {
|
||||
mkdir($upload_dir, 0775, true);
|
||||
}
|
||||
$ext = pathinfo($_FILES['logo']['name'], PATHINFO_EXTENSION);
|
||||
$filename = 'company_logo_' . time() . '.' . $ext;
|
||||
$target = $upload_dir . $filename;
|
||||
|
||||
if (move_uploaded_file($_FILES['logo']['tmp_name'], $target)) {
|
||||
$logo_path = 'assets/images/logo/' . $filename;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = db()->prepare("
|
||||
UPDATE company_settings
|
||||
SET company_name = ?, address_1 = ?, address_2 = ?, city = ?, province = ?,
|
||||
postal_code = ?, phone = ?, phone_2 = ?, email = ?, website = ?,
|
||||
fiscal_year_end = ?, business_number = ?, timezone = ?, sector = ?,
|
||||
notifications_enabled = ?, logo_path = ?
|
||||
WHERE id = 1
|
||||
");
|
||||
$stmt->execute([
|
||||
$company_name, $address_1, $address_2, $city, $province,
|
||||
$postal_code, $phone, $phone_2, $email, $website,
|
||||
$fiscal_year_end, $business_number, $timezone, $sector,
|
||||
$notifications_enabled, $logo_path
|
||||
]);
|
||||
$success = true;
|
||||
} catch (PDOException $e) {
|
||||
$error = "Error updating settings: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Current Settings
|
||||
$settings = db()->query("SELECT * FROM company_settings WHERE id = 1")->fetch();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Company Preferences";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">Company Preferences</h2>
|
||||
<p class="text-muted">Manage your business identity and application-wide settings.</p>
|
||||
</div>
|
||||
<a href="settings.php" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i> Back to Settings
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-check-circle-fill me-2"></i> Settings updated successfully!
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i> <?= htmlspecialchars($error) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" enctype="multipart/form-data" class="row g-4">
|
||||
<input type="hidden" name="current_logo" value="<?= htmlspecialchars($settings['logo_path'] ?? '') ?>">
|
||||
|
||||
<!-- Identity Card -->
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm overflow-hidden">
|
||||
<div class="card-header bg-white py-3 border-bottom">
|
||||
<h5 class="mb-0 fw-bold">Company Identity</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Company Legal Name</label>
|
||||
<input type="text" name="company_name" class="form-control" value="<?= htmlspecialchars($settings['company_name'] ?? '') ?>" required>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Sector</label>
|
||||
<select name="sector" class="form-select">
|
||||
<option value="">Select Sector...</option>
|
||||
<?php foreach ($sectors as $s): ?>
|
||||
<option value="<?= $s ?>" <?= ($settings['sector'] ?? '') === $s ? 'selected' : '' ?>><?= $s ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Business Number (CRA)</label>
|
||||
<input type="text" name="business_number" class="form-control" value="<?= htmlspecialchars($settings['business_number'] ?? '') ?>" placeholder="e.g. 123456789 RT 0001">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<label class="form-label small fw-bold d-block">Company Logo</label>
|
||||
<div class="mb-2">
|
||||
<?php if (!empty($settings['logo_path'])): ?>
|
||||
<img src="<?= htmlspecialchars($settings['logo_path']) ?>" alt="Logo" class="img-thumbnail mb-2" style="max-height: 120px;">
|
||||
<?php else: ?>
|
||||
<div class="bg-light rounded d-flex align-items-center justify-content-center mx-auto mb-2" style="width: 120px; height: 120px;">
|
||||
<i class="bi bi-image text-muted fs-1"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<input type="file" name="logo" class="form-control form-control-sm" accept="image/*">
|
||||
<p class="extra-small text-muted mt-2">Recommended: PNG or SVG with transparent background.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact & Address Card -->
|
||||
<div class="col-md-7">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3 border-bottom">
|
||||
<h5 class="mb-0 fw-bold">Address & Contact Info</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Address Line 1</label>
|
||||
<input type="text" name="address_1" class="form-control" value="<?= htmlspecialchars($settings['address_1'] ?? '') ?>" placeholder="Street address, P.O. box, company name, c/o">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Address Line 2 (Optional)</label>
|
||||
<input type="text" name="address_2" class="form-control" value="<?= htmlspecialchars($settings['address_2'] ?? '') ?>" placeholder="Apartment, suite, unit, building, floor, etc.">
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">City</label>
|
||||
<input type="text" name="city" class="form-control" value="<?= htmlspecialchars($settings['city'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Province</label>
|
||||
<select name="province" class="form-select">
|
||||
<option value="">--</option>
|
||||
<?php foreach ($provinces as $code => $name): ?>
|
||||
<option value="<?= $code ?>" <?= ($settings['province'] ?? '') === $code ? 'selected' : '' ?>><?= $code ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Postal Code</label>
|
||||
<input type="text" name="postal_code" class="form-control" value="<?= htmlspecialchars($settings['postal_code'] ?? '') ?>">
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Primary Phone</label>
|
||||
<input type="tel" name="phone" class="form-control" value="<?= htmlspecialchars($settings['phone'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Secondary Phone</label>
|
||||
<input type="tel" name="phone_2" class="form-control" value="<?= htmlspecialchars($settings['phone_2'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Email Address</label>
|
||||
<input type="email" name="email" class="form-control" value="<?= htmlspecialchars($settings['email'] ?? '') ?>">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">Website</label>
|
||||
<input type="url" name="website" class="form-control" value="<?= htmlspecialchars($settings['website'] ?? '') ?>" placeholder="https://">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Operations Card -->
|
||||
<div class="col-md-5">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3 border-bottom">
|
||||
<h5 class="mb-0 fw-bold">Operations</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold text-primary">Fiscal Year End</label>
|
||||
<input type="date" name="fiscal_year_end" class="form-control border-primary shadow-sm" value="<?= htmlspecialchars($settings['fiscal_year_end'] ?? '') ?>">
|
||||
<p class="extra-small text-muted mt-2">Important: This date is used for tax reporting and financial summaries.</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">Application Timezone</label>
|
||||
<select name="timezone" class="form-select">
|
||||
<option value="America/Toronto" <?= ($settings['timezone'] ?? '') === 'America/Toronto' ? 'selected' : '' ?>>Eastern Time (ET)</option>
|
||||
<option value="America/Winnipeg" <?= ($settings['timezone'] ?? '') === 'America/Winnipeg' ? 'selected' : '' ?>>Central Time (CT)</option>
|
||||
<option value="America/Edmonton" <?= ($settings['timezone'] ?? '') === 'America/Edmonton' ? 'selected' : '' ?>>Mountain Time (MT)</option>
|
||||
<option value="America/Vancouver" <?= ($settings['timezone'] ?? '') === 'America/Vancouver' ? 'selected' : '' ?>>Pacific Time (PT)</option>
|
||||
<option value="America/Halifax" <?= ($settings['timezone'] ?? '') === 'America/Halifax' ? 'selected' : '' ?>>Atlantic Time (AT)</option>
|
||||
<option value="America/St_Johns" <?= ($settings['timezone'] ?? '') === 'America/St_Johns' ? 'selected' : '' ?>>Newfoundland Time (NT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check form-switch p-3 bg-light rounded shadow-sm border">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="notifications_enabled" id="notifSwitch" <?= ($settings['notifications_enabled'] ?? 1) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold small" for="notifSwitch">Enable Company-wide Notifications</label>
|
||||
<p class="extra-small text-muted mb-0 mt-1">Global toggle for email alerts and system notifications.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary btn-lg shadow-sm py-3 fw-bold rounded-3">
|
||||
<i class="bi bi-save me-2"></i> Save All Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
43
db/migrations/001_initial_schema.sql
Normal file
43
db/migrations/001_initial_schema.sql
Normal file
@ -0,0 +1,43 @@
|
||||
-- Initial Schema for SR&ED Manager
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
role ENUM('global_admin', 'tenant_admin', 'staff') DEFAULT 'staff',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
code VARCHAR(50) NOT NULL,
|
||||
status ENUM('active', 'completed', 'on_hold') DEFAULT 'active',
|
||||
start_date DATE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
user_id INT,
|
||||
action VARCHAR(255) NOT NULL,
|
||||
details TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Seed Initial Demo Data
|
||||
INSERT IGNORE INTO tenants (id, name) VALUES (1, 'Acme Research Corp');
|
||||
INSERT IGNORE INTO users (id, tenant_id, name, email, role) VALUES (1, 1, 'John Manager', 'john@acme.com', 'tenant_admin');
|
||||
INSERT IGNORE INTO projects (tenant_id, name, code, start_date) VALUES (1, 'Project Alpha: Quantum AI', 'PA-001', '2025-01-01');
|
||||
INSERT IGNORE INTO activity_log (tenant_id, user_id, action, details) VALUES (1, 1, 'System Setup', 'Initial tenant environment created.');
|
||||
74
db/migrations/002_labour_module.sql
Normal file
74
db/migrations/002_labour_module.sql
Normal file
@ -0,0 +1,74 @@
|
||||
-- Migration for Labour Module and Centralized Attachments
|
||||
|
||||
-- Table for Employees
|
||||
CREATE TABLE IF NOT EXISTS employees (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
position VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Table for Labour Types (e.g., Experimental Development, Technical Support)
|
||||
CREATE TABLE IF NOT EXISTS labour_types (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Table for Objective Evidence Types (e.g., Logbooks, Test Results, Design Docs)
|
||||
CREATE TABLE IF NOT EXISTS evidence_types (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Table for Labour Entries
|
||||
CREATE TABLE IF NOT EXISTS labour_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
project_id INT NOT NULL,
|
||||
employee_id INT NOT NULL,
|
||||
entry_date DATE NOT NULL,
|
||||
hours DECIMAL(10, 2) NOT NULL,
|
||||
labour_type_id INT,
|
||||
evidence_type_id INT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (labour_type_id) REFERENCES labour_types(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (evidence_type_id) REFERENCES evidence_types(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Centralized Attachments Table
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL, -- 'labour_entry', 'project', 'expense', etc.
|
||||
entity_id INT NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_path VARCHAR(255) NOT NULL,
|
||||
file_size INT,
|
||||
mime_type VARCHAR(100),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Seed some initial settings for the demo tenant
|
||||
INSERT IGNORE INTO employees (tenant_id, name, position) VALUES (1, 'Alice Smith', 'Lead Researcher');
|
||||
INSERT IGNORE INTO employees (tenant_id, name, position) VALUES (1, 'Bob Jones', 'Developer');
|
||||
|
||||
INSERT IGNORE INTO labour_types (tenant_id, name) VALUES (1, 'Experimental Development');
|
||||
INSERT IGNORE INTO labour_types (tenant_id, name) VALUES (1, 'Technical Support');
|
||||
INSERT IGNORE INTO labour_types (tenant_id, name) VALUES (1, 'Technical Planning');
|
||||
|
||||
INSERT IGNORE INTO evidence_types (tenant_id, name) VALUES (1, 'Logbooks');
|
||||
INSERT IGNORE INTO evidence_types (tenant_id, name) VALUES (1, 'Test Results');
|
||||
INSERT IGNORE INTO evidence_types (tenant_id, name) VALUES (1, 'Design Documents');
|
||||
INSERT IGNORE INTO evidence_types (tenant_id, name) VALUES (1, 'Source Code Commits');
|
||||
48
db/migrations/003_expense_module.sql
Normal file
48
db/migrations/003_expense_module.sql
Normal file
@ -0,0 +1,48 @@
|
||||
-- Table for Suppliers/Contractors
|
||||
CREATE TABLE IF NOT EXISTS suppliers (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type ENUM('supplier', 'contractor') DEFAULT 'supplier',
|
||||
contact_info TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (tenant_id)
|
||||
);
|
||||
|
||||
-- Table for Expense Types (Company editable list)
|
||||
CREATE TABLE IF NOT EXISTS expense_types (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (tenant_id)
|
||||
);
|
||||
|
||||
-- Table for Expenses
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
project_id INT NOT NULL,
|
||||
supplier_id INT NOT NULL,
|
||||
expense_type_id INT NOT NULL,
|
||||
amount DECIMAL(15, 2) NOT NULL,
|
||||
allocation_percent DECIMAL(5, 2) DEFAULT 100.00,
|
||||
entry_date DATE NOT NULL,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
||||
FOREIGN KEY (supplier_id) REFERENCES suppliers(id),
|
||||
FOREIGN KEY (expense_type_id) REFERENCES expense_types(id),
|
||||
INDEX (tenant_id)
|
||||
);
|
||||
|
||||
-- Insert some default data for the demo tenant (tenant_id = 1)
|
||||
INSERT INTO suppliers (tenant_id, name, type, contact_info) VALUES
|
||||
(1, 'Cloud Services Inc.', 'supplier', 'billing@cloudservices.com'),
|
||||
(1, 'John Doe Consulting', 'contractor', 'john@doe.com');
|
||||
|
||||
INSERT INTO expense_types (tenant_id, name) VALUES
|
||||
(1, 'Materials'),
|
||||
(1, 'Subcontractors'),
|
||||
(1, 'Software Licenses'),
|
||||
(1, 'Overhead');
|
||||
24
db/migrations/004_company_settings.sql
Normal file
24
db/migrations/004_company_settings.sql
Normal file
@ -0,0 +1,24 @@
|
||||
CREATE TABLE IF NOT EXISTS company_settings (
|
||||
id INT PRIMARY KEY DEFAULT 1,
|
||||
company_name VARCHAR(255),
|
||||
address_1 VARCHAR(255),
|
||||
address_2 VARCHAR(255),
|
||||
city VARCHAR(100),
|
||||
province VARCHAR(100),
|
||||
postal_code VARCHAR(20),
|
||||
phone VARCHAR(20),
|
||||
phone_2 VARCHAR(20),
|
||||
email VARCHAR(255),
|
||||
website VARCHAR(255),
|
||||
fiscal_year_end DATE,
|
||||
business_number VARCHAR(100),
|
||||
timezone VARCHAR(100),
|
||||
sector VARCHAR(100),
|
||||
notifications_enabled TINYINT(1) DEFAULT 1,
|
||||
logo_path VARCHAR(255),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT single_row CHECK (id = 1)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
-- Initialize with default values if not exists
|
||||
INSERT IGNORE INTO company_settings (id, company_name) VALUES (1, 'My ERP Company');
|
||||
50
db/migrations/004_enhanced_employees.sql
Normal file
50
db/migrations/004_enhanced_employees.sql
Normal file
@ -0,0 +1,50 @@
|
||||
-- Migration for Enhanced Employee Management
|
||||
|
||||
-- 1. Alter employees table to add required fields
|
||||
ALTER TABLE employees ADD COLUMN first_name VARCHAR(100) AFTER tenant_id;
|
||||
ALTER TABLE employees ADD COLUMN last_name VARCHAR(100) AFTER first_name;
|
||||
ALTER TABLE employees ADD COLUMN start_date DATE AFTER position;
|
||||
ALTER TABLE employees ADD COLUMN is_limited BOOLEAN DEFAULT TRUE AFTER start_date;
|
||||
ALTER TABLE employees ADD COLUMN user_id INT NULL AFTER is_limited;
|
||||
ALTER TABLE employees ADD CONSTRAINT fk_employee_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- Migrate existing names to first/last (rough split)
|
||||
UPDATE employees SET first_name = SUBSTRING_INDEX(name, ' ', 1), last_name = SUBSTRING_INDEX(name, ' ', -1) WHERE name LIKE '% %';
|
||||
UPDATE employees SET first_name = name, last_name = '' WHERE name NOT LIKE '% %';
|
||||
|
||||
-- 2. Wage History Table
|
||||
CREATE TABLE IF NOT EXISTS employee_wages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
employee_id INT NOT NULL,
|
||||
hourly_rate DECIMAL(10, 2) NOT NULL,
|
||||
effective_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 3. Teams Table
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 4. Employee-Team Link (Many-to-Many)
|
||||
CREATE TABLE IF NOT EXISTS employee_teams (
|
||||
employee_id INT NOT NULL,
|
||||
team_id INT NOT NULL,
|
||||
tenant_id INT NOT NULL,
|
||||
PRIMARY KEY (employee_id, team_id),
|
||||
FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Seed some teams
|
||||
INSERT IGNORE INTO teams (tenant_id, name) VALUES (1, 'Engineering');
|
||||
INSERT IGNORE INTO teams (tenant_id, name) VALUES (1, 'R&D');
|
||||
INSERT IGNORE INTO teams (tenant_id, name) VALUES (1, 'Product Management');
|
||||
16
db/migrations/005_add_wage_to_labour.sql
Normal file
16
db/migrations/005_add_wage_to_labour.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- Add hourly_rate to labour_entries to capture wage at time of entry
|
||||
ALTER TABLE labour_entries ADD COLUMN hourly_rate DECIMAL(10, 2) DEFAULT NULL AFTER hours;
|
||||
|
||||
-- Backfill hourly_rate from employee_wages
|
||||
UPDATE labour_entries le
|
||||
JOIN (
|
||||
SELECT ew1.employee_id, ew1.hourly_rate, ew1.effective_date
|
||||
FROM employee_wages ew1
|
||||
WHERE ew1.effective_date = (
|
||||
SELECT MAX(ew2.effective_date)
|
||||
FROM employee_wages ew2
|
||||
WHERE ew2.employee_id = ew1.employee_id
|
||||
)
|
||||
) latest_wage ON le.employee_id = latest_wage.employee_id
|
||||
SET le.hourly_rate = latest_wage.hourly_rate
|
||||
WHERE le.hourly_rate IS NULL;
|
||||
7
db/migrations/005_enhanced_projects.sql
Normal file
7
db/migrations/005_enhanced_projects.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- Add new fields to projects table
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN owner_id INT NULL AFTER tenant_id,
|
||||
ADD COLUMN estimated_completion_date DATE NULL AFTER start_date,
|
||||
ADD COLUMN type ENUM('Internal', 'SRED') NOT NULL DEFAULT 'Internal' AFTER code,
|
||||
ADD COLUMN estimated_hours DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER type,
|
||||
ADD CONSTRAINT fk_project_owner FOREIGN KEY (owner_id) REFERENCES employees(id) ON DELETE SET NULL;
|
||||
27
db/migrations/006_auth_enhancements.sql
Normal file
27
db/migrations/006_auth_enhancements.sql
Normal file
@ -0,0 +1,27 @@
|
||||
-- Create password_resets table
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
token VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (email),
|
||||
INDEX (token)
|
||||
);
|
||||
|
||||
-- Create user_sessions table
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
country VARCHAR(100) DEFAULT NULL,
|
||||
user_agent TEXT DEFAULT NULL,
|
||||
login_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX (user_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Add tracking columns to users
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at DATETIME DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_ip VARCHAR(45) DEFAULT NULL;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS welcome_email_sent_at DATETIME DEFAULT NULL;
|
||||
15
db/migrations/20260215_create_clients_table.sql
Normal file
15
db/migrations/20260215_create_clients_table.sql
Normal file
@ -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;
|
||||
399
employee_detail.php
Normal file
399
employee_detail.php
Normal file
@ -0,0 +1,399 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
header('Location: employees.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = db();
|
||||
|
||||
// Fetch employee with user info
|
||||
$employee = $db->prepare("
|
||||
SELECT e.*, u.id as linked_user_id, u.email as user_email, u.last_login_at, u.welcome_email_sent_at
|
||||
FROM employees e
|
||||
LEFT JOIN users u ON e.user_id = u.id
|
||||
WHERE e.id = ?
|
||||
");
|
||||
$employee->execute([$id]);
|
||||
$employee = $employee->fetch();
|
||||
|
||||
if (!$employee) {
|
||||
die("Employee not found.");
|
||||
}
|
||||
|
||||
// Handle Welcome Email Request
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['send_welcome'])) {
|
||||
require_once __DIR__ . '/mail/MailService.php';
|
||||
|
||||
$targetUser = null;
|
||||
if ($employee['linked_user_id']) {
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmt->execute([$employee['linked_user_id']]);
|
||||
$targetUser = $stmt->fetch();
|
||||
} else if ($employee['email']) {
|
||||
// Create user if doesn't exist? For now let's assume we invite existing users or those with email
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE email = ?");
|
||||
$stmt->execute([$employee['email']]);
|
||||
$targetUser = $stmt->fetch();
|
||||
}
|
||||
|
||||
if (!$targetUser && $employee['email']) {
|
||||
// Create user if missing
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO users (tenant_id, name, email, role, require_password_change) VALUES (?, ?, ?, 'staff', 1)");
|
||||
$stmt->execute([$employee['tenant_id'], $employee['name'], $employee['email']]);
|
||||
$newUserId = $db->lastInsertId();
|
||||
|
||||
// Link employee to user
|
||||
$db->prepare("UPDATE employees SET user_id = ? WHERE id = ?")->execute([$newUserId, $id]);
|
||||
|
||||
// Fetch the new user
|
||||
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
|
||||
$stmt->execute([$newUserId]);
|
||||
$targetUser = $stmt->fetch();
|
||||
} catch (\Exception $e) {
|
||||
$error_msg = "Could not create user account: " . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetUser) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+48 hours'));
|
||||
|
||||
$db->prepare("INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, ?)")
|
||||
->execute([$targetUser['email'], $token, $expires]);
|
||||
|
||||
$setupLink = (isset($_SERVER['HTTPS']) ? "https" : "http") . "://$_SERVER[HTTP_HOST]/reset_password.php?token=$token";
|
||||
$subject = "Welcome to SR&ED Manager - Account Setup";
|
||||
$html = "
|
||||
<h3>Welcome to SR&ED Manager!</h3>
|
||||
<p>Your account has been created. Click the button below to set your password and get started.</p>
|
||||
<p><a href='$setupLink' style='padding: 10px 20px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 5px; display: inline-block;'>Set Up Account</a></p>
|
||||
<p>This link will expire in 48 hours.</p>
|
||||
";
|
||||
$text = "Welcome! Set up your account here: $setupLink";
|
||||
|
||||
if (MailService::sendMail($targetUser['email'], $subject, $html, $text)) {
|
||||
$db->prepare("UPDATE users SET welcome_email_sent_at = NOW() WHERE id = ?")
|
||||
->execute([$targetUser['id']]);
|
||||
$success_msg = "Welcome email sent to " . $targetUser['email'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = "Employee Detail: " . htmlspecialchars($employee['name']);
|
||||
include __DIR__ . '/includes/header.php';
|
||||
|
||||
// Fetch recent sessions
|
||||
$sessions = [];
|
||||
if ($employee['linked_user_id']) {
|
||||
$sessionStmt = $db->prepare("SELECT * FROM user_sessions WHERE user_id = ? ORDER BY login_at DESC LIMIT 5");
|
||||
$sessionStmt->execute([$employee['linked_user_id']]);
|
||||
$sessions = $sessionStmt->fetchAll();
|
||||
}
|
||||
|
||||
// Fetch recent labour entries
|
||||
$stmt = $db->prepare("
|
||||
SELECT l.*, p.name as project_name, lt.name as labour_type
|
||||
FROM labour_entries l
|
||||
JOIN projects p ON l.project_id = p.id
|
||||
JOIN labour_types lt ON l.labour_type_id = lt.id
|
||||
WHERE l.employee_id = ?
|
||||
ORDER BY l.entry_date DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$stmt->execute([$id]);
|
||||
$labourEntries = $stmt->fetchAll();
|
||||
|
||||
// Fetch summary stats
|
||||
$stats = $db->prepare("
|
||||
SELECT
|
||||
(SELECT SUM(hours) FROM labour_entries WHERE employee_id = ?) as total_hours,
|
||||
(SELECT COUNT(DISTINCT project_id) FROM labour_entries WHERE employee_id = ?) as project_count,
|
||||
(SELECT COUNT(*) FROM attachments a JOIN labour_entries le ON a.entity_id = le.id WHERE a.entity_type = 'labour_entry' AND le.employee_id = ?) as file_count
|
||||
");
|
||||
$stats->execute([$id, $id, $id]);
|
||||
$stats = $stats->fetch();
|
||||
|
||||
// Fetch recent expenses (linked via attachments uploaded by this employee name)
|
||||
$expenseStmt = $db->prepare("
|
||||
SELECT ex.*, et.name as expense_type, s.name as supplier_name, p.name as project_name
|
||||
FROM expenses ex
|
||||
JOIN expense_types et ON ex.expense_type_id = et.id
|
||||
LEFT JOIN suppliers s ON ex.supplier_id = s.id
|
||||
JOIN projects p ON ex.project_id = p.id
|
||||
JOIN attachments a ON a.entity_id = ex.id AND a.entity_type = 'expense'
|
||||
WHERE a.uploaded_by = ?
|
||||
ORDER BY ex.entry_date DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$expenseStmt->execute([$employee['name']]);
|
||||
$expenseEntries = $expenseStmt->fetchAll();
|
||||
|
||||
// Fetch recent files (uploaded by this employee or linked to their labour)
|
||||
$fileStmt = $db->prepare("
|
||||
SELECT a.*,
|
||||
COALESCE(le.entry_date, ex.entry_date) as entry_date,
|
||||
COALESCE(p1.name, p2.name) as project_name
|
||||
FROM attachments a
|
||||
LEFT JOIN labour_entries le ON a.entity_id = le.id AND a.entity_type = 'labour_entry'
|
||||
LEFT JOIN projects p1 ON le.project_id = p1.id
|
||||
LEFT JOIN expenses ex ON a.entity_id = ex.id AND a.entity_type = 'expense'
|
||||
LEFT JOIN projects p2 ON ex.project_id = p2.id
|
||||
WHERE a.uploaded_by = ? OR (a.entity_type = 'labour_entry' AND le.employee_id = ?)
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$fileStmt->execute([$employee['name'], $id]);
|
||||
$recentFiles = $fileStmt->fetchAll();
|
||||
|
||||
function formatBytes($bytes, $precision = 2) {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= pow(1024, $pow);
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a href="employees.php" class="text-decoration-none">Employees</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars($employee['name']) ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0"><?= htmlspecialchars($employee['name']) ?></h1>
|
||||
<p class="text-muted mb-0"><?= htmlspecialchars($employee['position'] ?? 'Staff') ?> • Joined <?= $employee['start_date'] ? date('M j, Y', strtotime($employee['start_date'])) : 'N/A' ?></p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<?php if (isset($success_msg)): ?>
|
||||
<div class="alert alert-success py-1 px-2 mb-0 small"><?= $success_msg ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if ($employee['email'] || $employee['linked_user_id']): ?>
|
||||
<form method="POST" class="d-inline">
|
||||
<button type="submit" name="send_welcome" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-envelope-at me-1"></i> <?= $employee['welcome_email_sent_at'] ? 'Resend' : 'Send' ?> Welcome Email
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-secondary btn-sm"><i class="bi bi-pencil me-1"></i> Edit Employee</button>
|
||||
<a href="labour.php?employee_id=<?= $id ?>" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg me-1"></i> Add Labour</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Total Hours</h6>
|
||||
<h3 class="mb-0 text-primary"><?= number_format($stats['total_hours'] ?? 0, 1) ?></h3>
|
||||
<small class="text-muted">Across all projects</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Projects</h6>
|
||||
<h3 class="mb-0 text-success"><?= number_format($stats['project_count'] ?? 0) ?></h3>
|
||||
<small class="text-muted">Involved in</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Files Uploaded</h6>
|
||||
<h3 class="mb-0 text-info"><?= number_format($stats['file_count'] ?? 0) ?></h3>
|
||||
<small class="text-muted">Labour & Expenses</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Email</h6>
|
||||
<h3 class="h5 mb-0 text-dark text-truncate"><?= htmlspecialchars($employee['email'] ?? 'N/A') ?></h3>
|
||||
<small class="text-muted">Contact Info</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Recent Labour -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Labour Entries</h6>
|
||||
<a href="labour.php?employee_id=<?= $id ?>" class="small text-decoration-none text-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr class="small text-muted text-uppercase">
|
||||
<th class="ps-3">Date</th>
|
||||
<th>Project</th>
|
||||
<th>Type</th>
|
||||
<th class="text-end pe-3">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($labourEntries)): ?>
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted">No entries found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($labourEntries as $le): ?>
|
||||
<tr>
|
||||
<td class="ps-3 small"><?= date('M j, Y', strtotime($le['entry_date'])) ?></td>
|
||||
<td class="small"><a href="project_detail.php?id=<?= $le['project_id'] ?>" class="text-decoration-none"><?= htmlspecialchars($le['project_name']) ?></a></td>
|
||||
<td class="small"><?= htmlspecialchars($le['labour_type']) ?></td>
|
||||
<td class="text-end pe-3 fw-bold text-primary"><?= number_format($le['hours'], 1) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Expenses -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Expenses Captured</h6>
|
||||
<a href="expenses.php" class="small text-decoration-none text-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr class="small text-muted text-uppercase">
|
||||
<th class="ps-3">Date</th>
|
||||
<th>Project</th>
|
||||
<th>Type</th>
|
||||
<th class="text-end pe-3">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($expenseEntries)): ?>
|
||||
<tr><td colspan="4" class="text-center py-4 text-muted">No expenses captured by this employee.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($expenseEntries as $ee): ?>
|
||||
<tr>
|
||||
<td class="ps-3 small"><?= date('M j, Y', strtotime($ee['entry_date'])) ?></td>
|
||||
<td class="small"><a href="project_detail.php?id=<?= $ee['project_id'] ?>" class="text-decoration-none text-truncate d-inline-block" style="max-width: 120px;"><?= htmlspecialchars($ee['project_name']) ?></a></td>
|
||||
<td class="small"><?= htmlspecialchars($ee['expense_type']) ?></td>
|
||||
<td class="text-end pe-3 fw-bold text-success">$<?= number_format($ee['amount'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Files -->
|
||||
<div class="col-md-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Files & Attachments</h6>
|
||||
<a href="files.php" class="small text-decoration-none text-primary">View All Files</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0 align-middle">
|
||||
<thead class="bg-light">
|
||||
<tr class="small text-muted text-uppercase">
|
||||
<th class="ps-3">Filename</th>
|
||||
<th>Linked To</th>
|
||||
<th>Size</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end pe-3">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($recentFiles)): ?>
|
||||
<tr><td colspan="5" class="text-center py-4 text-muted">No files found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($recentFiles as $f): ?>
|
||||
<tr>
|
||||
<td class="ps-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-file-earmark-text me-2 text-primary"></i>
|
||||
<span class="small fw-bold text-dark text-truncate d-inline-block" style="max-width: 150px;"><?= htmlspecialchars($f['file_name']) ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="badge bg-light text-dark border"><?= ucfirst($f['entity_type']) ?></span>
|
||||
</td>
|
||||
<td class="small text-muted"><?= formatBytes((int)$f['file_size']) ?></td>
|
||||
<td class="small text-muted"><?= date('M j, Y', strtotime($f['created_at'])) ?></td>
|
||||
<td class="text-end pe-3">
|
||||
<a href="<?= htmlspecialchars($f['file_path']) ?>" target="_blank" class="btn btn-sm btn-primary py-0 px-2">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Sessions -->
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h6 class="mb-0 fw-bold">Recent Sessions</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr class="extra-small text-muted text-uppercase">
|
||||
<th class="ps-3">Login Time</th>
|
||||
<th>IP / Country</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($sessions)): ?>
|
||||
<tr><td colspan="2" class="text-center py-4 text-muted small">No session history found.</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($sessions as $s): ?>
|
||||
<tr>
|
||||
<td class="ps-3 small">
|
||||
<div class="fw-bold"><?= date('M j, H:i', strtotime($s['login_at'])) ?></div>
|
||||
<div class="extra-small text-muted"><?= date('Y', strtotime($s['login_at'])) ?></div>
|
||||
</td>
|
||||
<td class="small">
|
||||
<div><?= htmlspecialchars($s['ip_address']) ?></div>
|
||||
<div class="extra-small text-muted"><i class="bi bi-geo-alt me-1"></i><?= htmlspecialchars($s['country'] ?? 'Unknown') ?></div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
262
employees.php
Normal file
262
employees.php
Normal file
@ -0,0 +1,262 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Add Employee
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) {
|
||||
$first_name = $_POST['first_name'] ?? '';
|
||||
$last_name = $_POST['last_name'] ?? '';
|
||||
$email = $_POST['email'] ?? '';
|
||||
$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) {
|
||||
$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, 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) {
|
||||
$stmt = db()->prepare("INSERT INTO employee_wages (tenant_id, employee_id, hourly_rate, effective_date) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $employee_id, $initial_wage, $start_date]);
|
||||
}
|
||||
|
||||
if (!empty($team_ids)) {
|
||||
foreach ($team_ids as $tid) {
|
||||
$stmt = db()->prepare("INSERT INTO employee_teams (tenant_id, employee_id, team_id) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $employee_id, $tid]);
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, 'Employee Created', "Added employee: $first_name $last_name"]);
|
||||
|
||||
header("Location: employees.php?success=1");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM employees e
|
||||
WHERE e.tenant_id = ?
|
||||
ORDER BY e.first_name, e.last_name
|
||||
");
|
||||
$employees->execute([$tenant_id]);
|
||||
$employeeList = $employees->fetchAll();
|
||||
|
||||
$teams = db()->prepare("SELECT * FROM teams WHERE tenant_id = ? ORDER BY name");
|
||||
$teams->execute([$tenant_id]);
|
||||
$teamList = $teams->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Employees";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Employees</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addEmployeeModal">+ New Employee</button>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Employee record successfully created.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Position</th>
|
||||
<th>Teams</th>
|
||||
<th>Wage</th>
|
||||
<th>Access</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($employeeList)): ?>
|
||||
<tr><td colspan="6" class="text-center py-5 text-muted">No employees found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="employee_detail.php?id=<?= $e['id'] ?>" class="text-decoration-none fw-bold text-dark">
|
||||
<?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?>
|
||||
</a>
|
||||
</td>
|
||||
<td class="small text-muted"><?= htmlspecialchars($e['position']) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$e_teams = db()->prepare("SELECT t.name FROM teams t JOIN employee_teams et ON t.id = et.team_id WHERE et.employee_id = ?");
|
||||
$e_teams->execute([$e['id']]);
|
||||
$t_names = $e_teams->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($t_names as $tn) {
|
||||
echo '<span class="badge bg-light text-dark border me-1">' . htmlspecialchars($tn) . '</span>';
|
||||
}
|
||||
?>
|
||||
</td>
|
||||
<td><span class="fw-bold text-success">$<?= number_format((float)($e['current_wage'] ?? 0), 2) ?>/h</span></td>
|
||||
<td><span class="badge <?= $e['is_limited'] ? 'bg-secondary' : 'bg-primary' ?>"><?= $e['is_limited'] ? 'Limited' : 'Regular' ?></span></td>
|
||||
<td class="text-end">
|
||||
<a href="employee_detail.php?id=<?= $e['id'] ?>" class="btn btn-sm btn-primary me-1">View</a>
|
||||
<button class="btn btn-sm btn-secondary">Edit</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="addEmployeeModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Add New Employee</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">First Name</label>
|
||||
<input type="text" name="first_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Last Name</label>
|
||||
<input type="text" name="last_name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Email</label>
|
||||
<input type="email" name="email" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Telephone (for 2FA)</label>
|
||||
<input type="text" name="phone" class="form-control" placeholder="+1234567890">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Position</label>
|
||||
<input type="text" name="position" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Hourly Wage ($)</label>
|
||||
<input type="number" name="initial_wage" class="form-control" step="0.01">
|
||||
</div>
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold d-block">Teams</label>
|
||||
<div class="row px-2">
|
||||
<?php foreach ($teamList as $t): ?>
|
||||
<div class="col-md-4 form-check">
|
||||
<input class="form-check-input" type="checkbox" name="teams[]" value="<?= $t['id'] ?>" id="teamCheck<?= $t['id'] ?>">
|
||||
<label class="form-check-label small" for="teamCheck<?= $t['id'] ?>"><?= htmlspecialchars($t['name']) ?></label>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 mb-3">
|
||||
<div class="form-check form-switch p-3 border rounded">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="is_limited" id="limitedCheck" checked onchange="togglePasswordFields()">
|
||||
<label class="form-check-label small fw-bold" for="limitedCheck">Limited Web Reporting (Cannot Login)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="passwordSection" style="display: none;">
|
||||
<div class="row p-3 border rounded bg-light mx-1 mb-3">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label small fw-bold">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="text" name="password" id="employeePassword" class="form-control" placeholder="Set or generate password">
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="generatePassword()">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="force_password_change" id="forceChange" checked>
|
||||
<label class="form-check-label small fw-bold" for="forceChange">Require change</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="extra-small text-muted">Passwords must meet complexity requirements defined in System Preferences.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" name="add_employee" class="btn btn-primary px-4">Create Employee</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePasswordFields() {
|
||||
const isLimited = document.getElementById('limitedCheck').checked;
|
||||
document.getElementById('passwordSection').style.display = isLimited ? 'none' : 'block';
|
||||
if (!isLimited) {
|
||||
document.getElementById('employeePassword').required = true;
|
||||
} else {
|
||||
document.getElementById('employeePassword').required = false;
|
||||
document.getElementById('employeePassword').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+";
|
||||
let retVal = "";
|
||||
for (let i = 0, n = charset.length; i < 12; ++i) {
|
||||
retVal += charset.charAt(Math.floor(Math.random() * n));
|
||||
}
|
||||
document.getElementById('employeePassword').value = retVal;
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', togglePasswordFields);
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
165
expense_files.php
Normal file
165
expense_files.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Filters
|
||||
$project_filter = $_GET['project_id'] ?? '';
|
||||
$start_date = $_GET['start_date'] ?? '';
|
||||
$end_date = $_GET['end_date'] ?? '';
|
||||
$include_archived = isset($_GET['include_archived']) && $_GET['include_archived'] === '1';
|
||||
|
||||
// Get Projects for filter
|
||||
$projects_sql = "SELECT id, name FROM projects WHERE tenant_id = ?";
|
||||
if (!$include_archived) {
|
||||
$projects_sql .= " AND is_archived = 0";
|
||||
}
|
||||
$projects_sql .= " ORDER BY name ASC";
|
||||
$projects_stmt = db()->prepare($projects_sql);
|
||||
$projects_stmt->execute([$tenant_id]);
|
||||
$all_projects = $projects_stmt->fetchAll();
|
||||
|
||||
$where_clauses = ["a.tenant_id = ?", "a.entity_type = 'expense'"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if (!$include_archived) {
|
||||
$where_clauses[] = "p.is_archived = 0";
|
||||
}
|
||||
|
||||
if ($project_filter) {
|
||||
$where_clauses[] = "ex.project_id = ?";
|
||||
$params[] = $project_filter;
|
||||
}
|
||||
if ($start_date) {
|
||||
$where_clauses[] = "ex.entry_date >= ?";
|
||||
$params[] = $start_date;
|
||||
}
|
||||
if ($end_date) {
|
||||
$where_clauses[] = "ex.entry_date <= ?";
|
||||
$params[] = $end_date;
|
||||
}
|
||||
|
||||
$where_sql = implode(" AND ", $where_clauses);
|
||||
|
||||
// Fetch Expense Files
|
||||
$stmt = db()->prepare("
|
||||
SELECT a.*, ex.entry_date, s.name as supplier_name, p.name as project_name
|
||||
FROM attachments a
|
||||
JOIN expenses ex ON a.entity_id = ex.id
|
||||
JOIN suppliers s ON ex.supplier_id = s.id
|
||||
JOIN projects p ON ex.project_id = p.id
|
||||
WHERE $where_sql
|
||||
ORDER BY a.created_at DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$files = $stmt->fetchAll();
|
||||
|
||||
function formatBytes($bytes, $precision = 2) {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= pow(1024, $pow);
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
$pageTitle = "SR&ED Manager - Expense Files";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Expense Files</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select">
|
||||
<option value="">All Projects</option>
|
||||
<?php foreach ($all_projects as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $project_filter == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= htmlspecialchars($start_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control" value="<?= htmlspecialchars($end_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center mt-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_archived" id="includeArchived" value="1" <?= $include_archived ? 'checked' : '' ?> onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="includeArchived">Include Archived</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="d-grid w-100 gap-2 d-md-flex">
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
<a href="expense_files.php" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded By</th>
|
||||
<th>Created At</th>
|
||||
<th>Linked Entry</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($files)): ?>
|
||||
<tr><td colspan="6" class="text-center py-5 text-muted">No expense files found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($files as $f): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-file-earmark-pdf me-2 text-danger"></i>
|
||||
<strong><?= htmlspecialchars($f['file_name']) ?></strong>
|
||||
</td>
|
||||
<td><small class="text-muted"><?= formatBytes((int)$f['file_size']) ?></small></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-light rounded-circle p-1 me-2" style="width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-person small"></i>
|
||||
</div>
|
||||
<small><?= htmlspecialchars($f['uploaded_by'] ?? 'System') ?></small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="small text-muted"><?= date('M j, Y H:i', strtotime($f['created_at'])) ?></td>
|
||||
<td>
|
||||
<div class="small">
|
||||
<span class="fw-bold text-dark"><?= $f['entry_date'] ?></span><br>
|
||||
<span class="text-muted"><?= htmlspecialchars($f['supplier_name']) ?> - <?= htmlspecialchars($f['project_name']) ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="<?= htmlspecialchars($f['file_path']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
285
expenses.php
Normal file
285
expenses.php
Normal file
@ -0,0 +1,285 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
require_once __DIR__ . '/includes/media_helper.php';
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Add Expense
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_expense'])) {
|
||||
$project_id = (int)($_POST['project_id'] ?? 0);
|
||||
$supplier_id = (int)($_POST['supplier_id'] ?? 0);
|
||||
$expense_type_id = (int)($_POST['expense_type_id'] ?? 0);
|
||||
$amount = (float)($_POST['amount'] ?? 0);
|
||||
$allocation = (float)($_POST['allocation_percent'] ?? 100);
|
||||
$entry_date = $_POST['entry_date'] ?? date('Y-m-d');
|
||||
$notes = $_POST['notes'] ?? '';
|
||||
|
||||
if ($project_id && $supplier_id && $amount > 0) {
|
||||
$stmt = db()->prepare("INSERT INTO expenses (tenant_id, project_id, supplier_id, expense_type_id, amount, allocation_percent, entry_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $project_id, $supplier_id, $expense_type_id, $amount, $allocation, $entry_date, $notes]);
|
||||
$expense_id = (int)db()->lastInsertId();
|
||||
|
||||
// Handle File Uploads
|
||||
if (!empty($_FILES['attachments']['name'][0])) {
|
||||
foreach ($_FILES['attachments']['tmp_name'] as $key => $tmp_name) {
|
||||
if ($_FILES['attachments']['error'][$key] === UPLOAD_ERR_OK) {
|
||||
$file_name = $_FILES['attachments']['name'][$key];
|
||||
$file_size = $_FILES['attachments']['size'][$key];
|
||||
$mime_type = $_FILES['attachments']['type'][$key];
|
||||
$file_ext = pathinfo($file_name, PATHINFO_EXTENSION);
|
||||
$new_file_name = uniqid() . '.' . $file_ext;
|
||||
$file_path = 'uploads/' . $new_file_name;
|
||||
|
||||
if (!is_dir('uploads')) mkdir('uploads', 0775, true);
|
||||
if (move_uploaded_file($tmp_name, $file_path)) {
|
||||
$thumbnail_path = null;
|
||||
if (isImage($mime_type)) {
|
||||
$thumb_name = 'thumb_' . $new_file_name;
|
||||
$thumb_path = 'uploads/' . $thumb_name;
|
||||
if (createThumbnail($file_path, $thumb_path)) {
|
||||
$thumbnail_path = $thumb_path;
|
||||
}
|
||||
}
|
||||
$stmt = db()->prepare("INSERT INTO attachments (tenant_id, entity_type, entity_id, file_name, file_path, thumbnail_path, file_size, mime_type, uploaded_by) VALUES (?, 'expense', ?, ?, ?, ?, ?, ?, 'John Manager')");
|
||||
$stmt->execute([$tenant_id, $expense_id, $file_name, $file_path, $thumbnail_path, $file_size, $mime_type]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, 'Expense Logged', "Logged \$" . number_format($amount, 2) . " expense for project ID $project_id"]);
|
||||
|
||||
header("Location: expenses.php?success=1");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Data
|
||||
$filter_project = (int)($_GET['project_id'] ?? 0);
|
||||
$filter_supplier = (int)($_GET['supplier_id'] ?? 0);
|
||||
$filter_start = $_GET['start_date'] ?? '';
|
||||
$filter_end = $_GET['end_date'] ?? '';
|
||||
|
||||
$where = ["e.tenant_id = ?"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if ($filter_project) {
|
||||
$where[] = "e.project_id = ?";
|
||||
$params[] = $filter_project;
|
||||
}
|
||||
if ($filter_supplier) {
|
||||
$where[] = "e.supplier_id = ?";
|
||||
$params[] = $filter_supplier;
|
||||
}
|
||||
if ($filter_start) {
|
||||
$where[] = "e.entry_date >= ?";
|
||||
$params[] = $filter_start;
|
||||
}
|
||||
if ($filter_end) {
|
||||
$where[] = "e.entry_date <= ?";
|
||||
$params[] = $filter_end;
|
||||
}
|
||||
|
||||
$where_clause = implode(" AND ", $where);
|
||||
|
||||
$expenseEntries = db()->prepare("
|
||||
SELECT e.*, p.name as project_name, s.name as supplier_name, et.name as expense_type
|
||||
FROM expenses e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
JOIN suppliers s ON e.supplier_id = s.id
|
||||
LEFT JOIN expense_types et ON e.expense_type_id = et.id
|
||||
WHERE $where_clause
|
||||
ORDER BY e.entry_date DESC, e.created_at DESC
|
||||
");
|
||||
$expenseEntries->execute($params);
|
||||
$expenseList = $expenseEntries->fetchAll();
|
||||
|
||||
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? AND is_archived = 0 ORDER BY name");
|
||||
$projects->execute([$tenant_id]);
|
||||
$projectList = $projects->fetchAll();
|
||||
|
||||
$suppliers = db()->prepare("SELECT * FROM suppliers WHERE tenant_id = ? ORDER BY name");
|
||||
$suppliers->execute([$tenant_id]);
|
||||
$supplierList = $suppliers->fetchAll();
|
||||
|
||||
$expenseTypes = db()->prepare("SELECT * FROM expense_types WHERE tenant_id = ? ORDER BY name");
|
||||
$expenseTypes->execute([$tenant_id]);
|
||||
$expenseTypeList = $expenseTypes->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Expenses";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Expenses</h2>
|
||||
<div>
|
||||
<a href="api/export_expenses.php?<?= http_build_query($_GET) ?>" class="btn btn-primary me-2"><i class="bi bi-file-earmark-excel me-1"></i> Export to Excel</a>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addExpenseModal">+ Add Expense</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select form-select-sm">
|
||||
<option value="">All Projects</option>
|
||||
<?php
|
||||
$allProjects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name");
|
||||
$allProjects->execute([$tenant_id]);
|
||||
foreach ($allProjects->fetchAll() as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $filter_project == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Supplier</label>
|
||||
<select name="supplier_id" class="form-select form-select-sm">
|
||||
<option value="">All Suppliers</option>
|
||||
<?php foreach ($supplierList as $s): ?>
|
||||
<option value="<?= $s['id'] ?>" <?= $filter_supplier == $s['id'] ? 'selected' : '' ?>><?= htmlspecialchars($s['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">From</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_start) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">To</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_end) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Filter</button>
|
||||
<a href="expenses.php" class="btn btn-sm btn-secondary w-100">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Expense successfully logged.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Supplier</th>
|
||||
<th>Project</th>
|
||||
<th>Amount</th>
|
||||
<th>Allocation</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($expenseList)): ?>
|
||||
<tr><td colspan="6" class="text-center py-5 text-muted">No expenses found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($expenseList as $ex): ?>
|
||||
<tr>
|
||||
<td class="text-muted"><?= $ex['entry_date'] ?></td>
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($ex['supplier_name']) ?></strong><br>
|
||||
<small class="text-muted"><?= htmlspecialchars($ex['expense_type'] ?? '') ?></small>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($ex['project_name']) ?></td>
|
||||
<td><span class="fw-bold">$<?= number_format((float)$ex['amount'], 2) ?></span></td>
|
||||
<td>
|
||||
<div class="progress mb-1" style="height: 6px; width: 100px;">
|
||||
<div class="progress-bar bg-info" role="progressbar" style="width: <?= $ex['allocation_percent'] ?>%"></div>
|
||||
</div>
|
||||
<small class="extra-small text-muted"><?= (float)$ex['allocation_percent'] ?>% SR&ED</small>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-secondary">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="addExpenseModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Add Expense</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select" required>
|
||||
<option value="">Select Project...</option>
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Supplier</label>
|
||||
<select name="supplier_id" class="form-select" required>
|
||||
<option value="">Select Supplier...</option>
|
||||
<?php foreach ($supplierList as $s): ?>
|
||||
<option value="<?= $s['id'] ?>"><?= htmlspecialchars($s['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Cost Type</label>
|
||||
<select name="expense_type_id" class="form-select">
|
||||
<?php foreach ($expenseTypeList as $et): ?>
|
||||
<option value="<?= $et['id'] ?>"><?= htmlspecialchars($et['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label small fw-bold">Amount ($)</label>
|
||||
<input type="number" name="amount" class="form-control" step="0.01" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label small fw-bold">Allocation (%)</label>
|
||||
<input type="number" name="allocation_percent" class="form-control" value="100">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label small fw-bold">Date</label>
|
||||
<input type="date" name="entry_date" class="form-control" value="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<label class="form-label small fw-bold">Receipts</label>
|
||||
<input type="file" name="attachments[]" class="form-control" multiple>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" name="add_expense" class="btn btn-primary px-4">Log Expense</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
266
export_pdf.php
Normal file
266
export_pdf.php
Normal file
@ -0,0 +1,266 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
require_once '/usr/share/php/dompdf/autoload.php';
|
||||
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
|
||||
// Simulate Tenant Context
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
$report_type = $_GET['report_type'] ?? 'labour_export';
|
||||
|
||||
// Filter Data (Same as reports.php)
|
||||
$f_employee = $_GET['employee_id'] ?? '';
|
||||
$f_team = $_GET['team_id'] ?? '';
|
||||
$f_start = $_GET['start_date'] ?? date('Y-m-01');
|
||||
$f_end = $_GET['end_date'] ?? date('Y-m-t');
|
||||
$f_projects = $_GET['projects'] ?? [];
|
||||
$f_activity = $_GET['activity_type_id'] ?? '';
|
||||
|
||||
// Handle Results for Labour Export
|
||||
$results = [];
|
||||
if ($report_type === 'labour_export') {
|
||||
$query = "
|
||||
SELECT le.*, p.name as project_name, e.name as employee_name, lt.name as labour_type
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN labour_types lt ON le.labour_type_id = lt.id
|
||||
WHERE le.tenant_id = ? AND le.entry_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$tenant_id, $f_start, $f_end];
|
||||
|
||||
if ($f_employee) {
|
||||
$query .= " AND le.employee_id = ?";
|
||||
$params[] = $f_employee;
|
||||
}
|
||||
|
||||
if ($f_team) {
|
||||
$query .= " AND le.employee_id IN (SELECT employee_id FROM employee_teams WHERE team_id = ?)";
|
||||
$params[] = $f_team;
|
||||
}
|
||||
|
||||
if (!empty($f_projects)) {
|
||||
$placeholders = implode(',', array_fill(0, count($f_projects), '?'));
|
||||
$query .= " AND le.project_id IN ($placeholders)";
|
||||
foreach ($f_projects as $pid) $params[] = $pid;
|
||||
}
|
||||
|
||||
if ($f_activity) {
|
||||
$query .= " AND le.labour_type_id = ?";
|
||||
$params[] = $f_activity;
|
||||
}
|
||||
|
||||
$query .= " ORDER BY le.entry_date ASC";
|
||||
$stmt = db()->prepare($query);
|
||||
$stmt->execute($params);
|
||||
$results = $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// Handle Calendar Report
|
||||
$calendarData = [];
|
||||
$cal_month = $_GET['month'] ?? date('Y-m');
|
||||
if ($report_type === 'calendar') {
|
||||
$stmt = db()->prepare("
|
||||
SELECT entry_date, SUM(hours) as total_hours
|
||||
FROM labour_entries
|
||||
WHERE tenant_id = ? AND DATE_FORMAT(entry_date, '%Y-%m') = ?
|
||||
GROUP BY entry_date
|
||||
");
|
||||
$stmt->execute([$tenant_id, $cal_month]);
|
||||
$calendarData = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
// Fetch filter names for header (Labour Export only)
|
||||
$employee_name = 'All Employees';
|
||||
if ($report_type === 'labour_export') {
|
||||
if ($f_employee) {
|
||||
$stmt = db()->prepare("SELECT name FROM employees WHERE id = ?");
|
||||
$stmt->execute([$f_employee]);
|
||||
$employee_name = $stmt->fetchColumn() ?: 'All Employees';
|
||||
}
|
||||
|
||||
$project_names = 'All Projects';
|
||||
if (!empty($f_projects)) {
|
||||
$placeholders = implode(',', array_fill(0, count($f_projects), '?'));
|
||||
$stmt = db()->prepare("SELECT name FROM projects WHERE id IN ($placeholders)");
|
||||
$stmt->execute($f_projects);
|
||||
$p_list = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
$project_names = implode(', ', $p_list);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate HTML
|
||||
$html = '
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 12px; color: #333; }
|
||||
.header { text-align: center; margin-bottom: 20px; border-bottom: 2px solid #3b82f6; padding-bottom: 10px; }
|
||||
.header h1 { margin: 0; color: #0f172a; font-size: 20px; }
|
||||
.filters { margin-bottom: 20px; background: #f8fafc; padding: 10px; border-radius: 5px; border: 1px solid #e2e8f0; }
|
||||
.filters table { width: 100%; }
|
||||
.label { font-weight: bold; color: #64748b; font-size: 10px; }
|
||||
table.data { width: 100%; border-collapse: collapse; margin-top: 10px; }
|
||||
table.data th { background: #f1f5f9; color: #475569; text-align: left; padding: 8px; border: 1px solid #e2e8f0; text-transform: uppercase; font-size: 9px; }
|
||||
table.data td { padding: 8px; border: 1px solid #e2e8f0; }
|
||||
.text-end { text-align: right; }
|
||||
.footer { margin-top: 30px; text-align: center; font-size: 9px; color: #94a3b8; }
|
||||
.total-row { background: #f8fafc; font-weight: bold; }
|
||||
.badge { background: #f1f5f9; padding: 2px 5px; border-radius: 3px; border: 1px solid #e2e8f0; font-size: 9px; }
|
||||
|
||||
/* Calendar Styles for PDF */
|
||||
.calendar { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
||||
.calendar th { background: #f1f5f9; padding: 10px; border: 1px solid #e2e8f0; text-transform: uppercase; font-size: 10px; color: #64748b; }
|
||||
.calendar td { height: 70px; vertical-align: top; padding: 5px; border: 1px solid #e2e8f0; background: #fff; }
|
||||
.day-num { font-weight: bold; color: #94a3b8; margin-bottom: 5px; display: block; }
|
||||
.day-hours { color: #3b82f6; font-weight: bold; font-size: 14px; text-align: right; display: block; margin-top: 10px; }
|
||||
.other-month { background: #f8fafc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>' . ($report_type === 'calendar' ? 'Monthly Labour Calendar' : 'SR&ED Labour Report') . '</h1>
|
||||
<p style="margin-top: 5px; color: #64748b;">Generated on ' . date('F j, Y, g:i a') . '</p>
|
||||
</div>';
|
||||
|
||||
if ($report_type === 'labour_export') {
|
||||
$html .= '
|
||||
<div class="filters">
|
||||
<table cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td class="label" width="15%">Period:</td>
|
||||
<td width="35%">' . date('M j, Y', strtotime($f_start)) . ' to ' . date('M j, Y', strtotime($f_end)) . '</td>
|
||||
<td class="label" width="15%">Employee:</td>
|
||||
<td width="35%">' . htmlspecialchars($employee_name) . '</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Projects:</td>
|
||||
<td colspan="3">' . htmlspecialchars($project_names) . '</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Project</th>
|
||||
<th>Activity</th>
|
||||
<th class="text-end">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>';
|
||||
|
||||
$total_hours = 0;
|
||||
foreach ($results as $row) {
|
||||
$total_hours += (float)$row['hours'];
|
||||
$html .= '
|
||||
<tr>
|
||||
<td>' . $row['entry_date'] . '</td>
|
||||
<td><strong>' . htmlspecialchars($row['employee_name']) . '</strong></td>
|
||||
<td>' . htmlspecialchars($row['project_name']) . '</td>
|
||||
<td><span class="badge">' . htmlspecialchars($row['labour_type'] ?? 'Uncategorized') . '</span></td>
|
||||
<td class="text-end">' . number_format((float)$row['hours'], 2) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
$html .= '<tr><td colspan="5" style="text-align:center; padding: 20px;">No records found.</td></tr>';
|
||||
}
|
||||
|
||||
$html .= '
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="total-row">
|
||||
<td colspan="4" class="text-end">Total Hours:</td>
|
||||
<td class="text-end" style="color: #3b82f6;">' . number_format($total_hours, 2) . '</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>';
|
||||
} else {
|
||||
// Calendar View
|
||||
$html .= '
|
||||
<div class="filters" style="text-align: center; font-size: 14px; font-weight: bold; color: #0f172a;">
|
||||
' . date('F Y', strtotime($cal_month)) . '
|
||||
</div>
|
||||
|
||||
<table class="calendar">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sun</th><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>';
|
||||
|
||||
$first_day = date('Y-m-01', strtotime($cal_month));
|
||||
$days_in_month = (int)date('t', strtotime($cal_month));
|
||||
$start_day_of_week = (int)date('w', strtotime($first_day));
|
||||
|
||||
$cal_day_count = 0;
|
||||
$html .= '<tr>';
|
||||
// Fill empty days before first of month
|
||||
for ($i = 0; $i < $start_day_of_week; $i++) {
|
||||
$html .= '<td class="other-month"></td>';
|
||||
$cal_day_count++;
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for ($day = 1; $day <= $days_in_month; $day++) {
|
||||
if ($cal_day_count > 0 && ($cal_day_count % 7) == 0) {
|
||||
$html .= '</tr><tr>';
|
||||
}
|
||||
|
||||
$current_date = date('Y-m-', strtotime($cal_month)) . str_pad((string)$day, 2, '0', STR_PAD_LEFT);
|
||||
$hours = $calendarData[$current_date] ?? 0;
|
||||
|
||||
$html .= '<td>';
|
||||
$html .= '<span class="day-num">' . $day . '</span>';
|
||||
if ($hours > 0) {
|
||||
$html .= '<span class="day-hours">' . number_format((float)$hours, 1) . 'h</span>';
|
||||
}
|
||||
$html .= '</td>';
|
||||
$cal_day_count++;
|
||||
}
|
||||
|
||||
// Fill remaining days to complete grid
|
||||
while ($cal_day_count % 7 != 0) {
|
||||
$html .= '<td class="other-month"></td>';
|
||||
$cal_day_count++;
|
||||
}
|
||||
$html .= '</tr>';
|
||||
|
||||
$html .= '
|
||||
</tbody>
|
||||
</table>';
|
||||
}
|
||||
|
||||
$html .= '
|
||||
<div class="footer">
|
||||
<p>This report is for internal ERP and SR&ED documentation purposes.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
// Setup Dompdf
|
||||
$options = new Options();
|
||||
$options->set('isHtml5ParserEnabled', true);
|
||||
$options->set('isRemoteEnabled', true);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
// Output the PDF
|
||||
$prefix = $report_type === 'calendar' ? 'Monthly_Calendar_' : 'Labour_Report_';
|
||||
$filename = $prefix . date('Ymd_His') . '.pdf';
|
||||
$dompdf->stream($filename, ['Attachment' => true]);
|
||||
188
files.php
Normal file
188
files.php
Normal file
@ -0,0 +1,188 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Filters
|
||||
$project_filter = $_GET['project_id'] ?? '';
|
||||
$start_date = $_GET['start_date'] ?? '';
|
||||
$end_date = $_GET['end_date'] ?? '';
|
||||
$include_archived = isset($_GET['include_archived']) && $_GET['include_archived'] === '1';
|
||||
|
||||
// Get Projects for filter
|
||||
$projects_sql = "SELECT id, name FROM projects WHERE tenant_id = ?";
|
||||
if (!$include_archived) {
|
||||
$projects_sql .= " AND is_archived = 0";
|
||||
}
|
||||
$projects_sql .= " ORDER BY name ASC";
|
||||
$projects_stmt = db()->prepare($projects_sql);
|
||||
$projects_stmt->execute([$tenant_id]);
|
||||
$all_projects = $projects_stmt->fetchAll();
|
||||
|
||||
$where_clauses = ["a.tenant_id = ?"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if (!$include_archived) {
|
||||
$where_clauses[] = "(lp.is_archived = 0 OR ep.is_archived = 0 OR (a.entity_type NOT IN ('labour_entry', 'expense')))";
|
||||
}
|
||||
|
||||
if ($project_filter) {
|
||||
$where_clauses[] = "(le.project_id = ? OR ex.project_id = ?)";
|
||||
$params[] = $project_filter;
|
||||
$params[] = $project_filter;
|
||||
}
|
||||
if ($start_date) {
|
||||
$where_clauses[] = "(le.entry_date >= ? OR ex.entry_date >= ?)";
|
||||
$params[] = $start_date;
|
||||
$params[] = $start_date;
|
||||
}
|
||||
if ($end_date) {
|
||||
$where_clauses[] = "(le.entry_date <= ? OR ex.entry_date <= ?)";
|
||||
$params[] = $end_date;
|
||||
$params[] = $end_date;
|
||||
}
|
||||
|
||||
$where_sql = implode(" AND ", $where_clauses);
|
||||
|
||||
// Fetch All Files with their related context using LEFT JOINs for better filtering
|
||||
$stmt = db()->prepare("
|
||||
SELECT a.*,
|
||||
le.entry_date as labour_date, e.name as employee_name, lp.name as labour_project,
|
||||
ex.entry_date as expense_date, s.name as supplier_name, ep.name as expense_project
|
||||
FROM attachments a
|
||||
LEFT JOIN labour_entries le ON a.entity_id = le.id AND a.entity_type = 'labour_entry'
|
||||
LEFT JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN projects lp ON le.project_id = lp.id
|
||||
LEFT JOIN expenses ex ON a.entity_id = ex.id AND a.entity_type = 'expense'
|
||||
LEFT JOIN suppliers s ON ex.supplier_id = s.id
|
||||
LEFT JOIN projects ep ON ex.project_id = ep.id
|
||||
WHERE $where_sql
|
||||
ORDER BY a.created_at DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$files = $stmt->fetchAll();
|
||||
|
||||
function formatBytes($bytes, $precision = 2) {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= pow(1024, $pow);
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
$pageTitle = "SR&ED Manager - All Files Report";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">System Files Report</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select">
|
||||
<option value="">All Projects</option>
|
||||
<?php foreach ($all_projects as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $project_filter == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= htmlspecialchars($start_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control" value="<?= htmlspecialchars($end_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center mt-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_archived" id="includeArchived" value="1" <?= $include_archived ? 'checked' : '' ?> onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="includeArchived">Include Archived</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="d-grid w-100 gap-2 d-md-flex">
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
<a href="files.php" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded By</th>
|
||||
<th>Created At</th>
|
||||
<th>Linked Entry</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($files)): ?>
|
||||
<tr><td colspan="7" class="text-center py-5 text-muted">No files found in the system.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($files as $f): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php if ($f['entity_type'] === 'labour_entry'): ?>
|
||||
<span class="badge bg-soft-primary text-primary border">Labour</span>
|
||||
<?php elseif ($f['entity_type'] === 'expense'): ?>
|
||||
<span class="badge bg-soft-success text-success border">Expense</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-soft-secondary text-secondary border"><?= ucfirst($f['entity_type']) ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<strong><?= htmlspecialchars($f['file_name']) ?></strong>
|
||||
</td>
|
||||
<td><small class="text-muted"><?= formatBytes((int)$f['file_size']) ?></small></td>
|
||||
<td><small><?= htmlspecialchars($f['uploaded_by'] ?? 'System') ?></small></td>
|
||||
<td class="small text-muted"><?= date('M j, Y', strtotime($f['created_at'])) ?></td>
|
||||
<td>
|
||||
<small class="text-muted text-truncate d-inline-block" style="max-width: 300px;">
|
||||
<?php
|
||||
if ($f['entity_type'] === 'labour_entry') {
|
||||
echo htmlspecialchars(($f['labour_date'] ?? '') . ' - ' . ($f['employee_name'] ?? '') . ' - ' . ($f['labour_project'] ?? ''));
|
||||
} elseif ($f['entity_type'] === 'expense') {
|
||||
echo htmlspecialchars(($f['expense_date'] ?? '') . ' - ' . ($f['supplier_name'] ?? '') . ' - ' . ($f['expense_project'] ?? ''));
|
||||
} else {
|
||||
echo 'N/A';
|
||||
}
|
||||
?>
|
||||
</small>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="<?= htmlspecialchars($f['file_path']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bg-soft-primary { background-color: rgba(59, 130, 246, 0.1); }
|
||||
.bg-soft-success { background-color: rgba(34, 197, 94, 0.1); }
|
||||
.bg-soft-secondary { background-color: rgba(107, 114, 128, 0.1); }
|
||||
</style>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
88
forgot_password.php
Normal file
88
forgot_password.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
require_once __DIR__ . '/mail/MailService.php';
|
||||
|
||||
$success = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = $_POST['email'] ?? '';
|
||||
$ip = Auth::getIpAddress();
|
||||
|
||||
// Record attempt for security tracking
|
||||
Auth::recordResetAttempt($email, $ip);
|
||||
|
||||
$stmt = db()->prepare("SELECT id FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user) {
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expires = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO password_resets (email, token, expires_at) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$email, $token, $expires]);
|
||||
|
||||
// Send Email
|
||||
$resetLink = (isset($_SERVER['HTTPS']) ? "https" : "http") . "://$_SERVER[HTTP_HOST]/reset_password.php?token=$token";
|
||||
$subject = "Password Reset Request";
|
||||
$html = "
|
||||
<h3>Password Reset Request</h3>
|
||||
<p>We received a request to reset your password for SR&ED Manager.</p>
|
||||
<p>Click the link below to set a new password. This link will expire in 1 hour.</p>
|
||||
<p><a href='$resetLink' style='padding: 10px 20px; background-color: #3b82f6; color: white; text-decoration: none; border-radius: 5px; display: inline-block;'>Reset Password</a></p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
";
|
||||
$text = "Reset your password by clicking this link: $resetLink";
|
||||
|
||||
MailService::sendMail($email, $subject, $html, $text);
|
||||
}
|
||||
|
||||
// Always show success to prevent email enumeration
|
||||
$success = true;
|
||||
}
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Forgot Password - SR&ED Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.login-card { width: 100%; max-width: 400px; border: none; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); }
|
||||
.btn-primary { background-color: #3b82f6; border: none; padding: 10px; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="fw-bold text-primary">RESET PASSWORD</h3>
|
||||
<p class="text-muted small">Enter your email to receive a reset link</p>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success small">
|
||||
If an account exists with that email, you will receive a password reset link shortly.
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<a href="login.php" class="btn btn-outline-secondary w-100">Back to Login</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Email Address</label>
|
||||
<input type="email" name="email" class="form-control" placeholder="name@company.com" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 mb-3">Send Reset Link</button>
|
||||
<div class="text-center">
|
||||
<a href="login.php" class="small text-decoration-none">Back to Login</a>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
189
import_expenses.php
Normal file
189
import_expenses.php
Normal file
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
$pageTitle = 'Import Expenses';
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
$results = [];
|
||||
|
||||
// Helper to get maps for lookups
|
||||
function getLookupMaps() {
|
||||
$db = db();
|
||||
$maps = [
|
||||
'projects' => [],
|
||||
'suppliers' => [],
|
||||
'types' => []
|
||||
];
|
||||
|
||||
$s = $db->query("SELECT id, project_code FROM projects");
|
||||
while($r = $s->fetch(PDO::FETCH_ASSOC)) $maps['projects'][strtolower($r['project_code'])] = $r['id'];
|
||||
|
||||
$s = $db->query("SELECT id, name FROM suppliers");
|
||||
while($r = $s->fetch(PDO::FETCH_ASSOC)) $maps['suppliers'][strtolower($r['name'])] = $r['id'];
|
||||
|
||||
$s = $db->query("SELECT id, name FROM expense_types");
|
||||
while($r = $s->fetch(PDO::FETCH_ASSOC)) $maps['types'][strtolower($r['name'])] = $r['id'];
|
||||
|
||||
return $maps;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$file = $_FILES['csv_file'];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$error = 'File upload failed.';
|
||||
} else {
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
$expected = ['project_code', 'supplier_name', 'expense_type', 'amount', 'date', 'notes'];
|
||||
|
||||
if (!$header || count(array_intersect($expected, $header)) < 3) {
|
||||
$error = 'Invalid CSV format. Please ensure project_code, supplier_name, and expense_type are present.';
|
||||
} else {
|
||||
$columnMap = array_flip($header);
|
||||
$maps = getLookupMaps();
|
||||
$rowCount = 0;
|
||||
$importCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
db()->beginTransaction();
|
||||
try {
|
||||
$stmt = db()->prepare("INSERT INTO expenses (tenant_id, project_id, supplier_id, expense_type_id, amount, allocation_percent, entry_date, 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]]) : '';
|
||||
}
|
||||
|
||||
$projId = $maps['projects'][strtolower($data['project_code'])] ?? null;
|
||||
$suppId = $maps['suppliers'][strtolower($data['supplier_name'])] ?? null;
|
||||
$typeId = $maps['types'][strtolower($data['expense_type'])] ?? null;
|
||||
|
||||
if (!$projId) {
|
||||
$results[] = "Row $rowCount: Skipped (Unknown Project Code: " . htmlspecialchars($data['project_code']) . ")";
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (!$suppId) {
|
||||
$results[] = "Row $rowCount: Skipped (Unknown Supplier: " . htmlspecialchars($data['supplier_name']) . ")";
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (!$typeId) {
|
||||
$results[] = "Row $rowCount: Skipped (Unknown Expense Type: " . htmlspecialchars($data['expense_type']) . ")";
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = floatval($data['amount']);
|
||||
$date = empty($data['date']) ? date('Y-m-d') : $data['date'];
|
||||
|
||||
$stmt->execute([
|
||||
1, // tenant_id
|
||||
$projId,
|
||||
$suppId,
|
||||
$typeId,
|
||||
$amount,
|
||||
100.00, // Default allocation_percent
|
||||
$date,
|
||||
$data['notes']
|
||||
]);
|
||||
$importCount++;
|
||||
}
|
||||
db()->commit();
|
||||
$message = "Import completed. $importCount expenses imported, $skippedCount skipped.";
|
||||
} catch (Exception $e) {
|
||||
db()->rollBack();
|
||||
$error = 'Database error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
include 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-receipt me-2"></i>Import Expenses</h2>
|
||||
<a href="samples/expenses_template.csv" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= $message ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= $error ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<form action="import_expenses.php" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label for="csv_file" class="form-label fw-bold">Select CSV File</label>
|
||||
<input type="file" class="form-control" id="csv_file" name="csv_file" accept=".csv" required>
|
||||
<div class="form-text mt-2">
|
||||
Max file size: 2MB. Only .csv files are allowed.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-upload me-2"></i>Upload and Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="small text-muted">
|
||||
<li>Download the CSV template.</li>
|
||||
<li>Ensure <strong>project_code</strong>, <strong>supplier_name</strong>, and <strong>expense_type</strong> match existing records in the system.</li>
|
||||
<li><strong>amount</strong> should be a numeric value.</li>
|
||||
<li><strong>date</strong> should be in YYYY-MM-DD format (defaults to today if empty).</li>
|
||||
</ol>
|
||||
<div class="alert alert-warning py-2 small mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i> Rows with unknown projects, suppliers, or types will be skipped.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($results)): ?>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Logs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush small">
|
||||
<?php foreach ($results as $res): ?>
|
||||
<div class="list-group-item px-0 border-0 py-1">
|
||||
<i class="bi bi-dot me-1"></i><?= $res ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
185
import_labour.php
Normal file
185
import_labour.php
Normal file
@ -0,0 +1,185 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
$pageTitle = 'Import Labour Activities';
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
$results = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$file = $_FILES['csv_file'];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$error = 'File upload failed.';
|
||||
} else {
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
$expected = ['project_code', 'employee_email', 'date', 'hours', 'labour_type', 'evidence_type', 'notes'];
|
||||
|
||||
if (!$header || count(array_intersect($expected, $header)) < 1) {
|
||||
$error = 'Invalid CSV format. Please use the provided template.';
|
||||
} else {
|
||||
$columnMap = array_flip($header);
|
||||
|
||||
// Pre-fetch lookups
|
||||
$projects = [];
|
||||
$stmt = db()->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';
|
||||
?>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-clock-history me-2"></i>Import Labour Activities</h2>
|
||||
<a href="samples/labour_template.csv" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= $message ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= $error ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<form action="import_labour.php" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label for="csv_file" class="form-label fw-bold">Select CSV File</label>
|
||||
<input type="file" class="form-control" id="csv_file" name="csv_file" accept=".csv" required>
|
||||
<div class="form-text mt-2">
|
||||
Max file size: 5MB. Only .csv files are allowed.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-upload me-2"></i>Upload and Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="small text-muted">
|
||||
<li>Download the CSV template using the button above.</li>
|
||||
<li>Ensure <strong>Project Code</strong> and <strong>Employee Email</strong> match existing records.</li>
|
||||
<li><strong>Date</strong> should be in YYYY-MM-DD format.</li>
|
||||
<li><strong>Labour Type</strong> must match one of:
|
||||
<ul class="mb-0">
|
||||
<li>Experimental Development</li>
|
||||
<li>Technical Support</li>
|
||||
<li>Technical Planning</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Upload and review the logs for any skipped rows.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($results)): ?>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Logs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush small">
|
||||
<?php foreach ($results as $res): ?>
|
||||
<div class="list-group-item px-0 border-0 py-1">
|
||||
<i class="bi bi-exclamation-triangle-fill text-warning me-1"></i><?= $res ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
152
import_suppliers.php
Normal file
152
import_suppliers.php
Normal file
@ -0,0 +1,152 @@
|
||||
<?php
|
||||
require_once 'db/config.php';
|
||||
$pageTitle = 'Import Suppliers';
|
||||
|
||||
$message = '';
|
||||
$error = '';
|
||||
$results = [];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) {
|
||||
$file = $_FILES['csv_file'];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
$error = 'File upload failed.';
|
||||
} else {
|
||||
$handle = fopen($file['tmp_name'], 'r');
|
||||
$header = fgetcsv($handle);
|
||||
|
||||
// Expected columns: name, type, contact_info
|
||||
$expected = ['name', 'type', 'contact_info'];
|
||||
|
||||
if (!$header || count(array_intersect($expected, $header)) < 1) {
|
||||
$error = 'Invalid CSV format. Please use the provided template.';
|
||||
} else {
|
||||
$columnMap = array_flip($header);
|
||||
$rowCount = 0;
|
||||
$importCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
db()->beginTransaction();
|
||||
try {
|
||||
$stmt = db()->prepare("INSERT INTO suppliers (tenant_id, name, type, contact_info) 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;
|
||||
}
|
||||
|
||||
// Default type if invalid
|
||||
if (!in_array($data['type'], ['supplier', 'contractor'])) {
|
||||
$data['type'] = 'supplier';
|
||||
}
|
||||
|
||||
$stmt->execute([
|
||||
1, // Hardcoded tenant_id
|
||||
$data['name'],
|
||||
$data['type'],
|
||||
$data['contact_info']
|
||||
]);
|
||||
$importCount++;
|
||||
}
|
||||
db()->commit();
|
||||
$message = "Import completed successfully. $importCount suppliers imported, $skippedCount skipped.";
|
||||
} catch (Exception $e) {
|
||||
db()->rollBack();
|
||||
$error = 'Database error: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
include 'includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="bi bi-truck me-2"></i>Import Suppliers</h2>
|
||||
<a href="samples/suppliers_template.csv" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<?= $message ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<?= $error ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body p-4">
|
||||
<form action="import_suppliers.php" method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-4">
|
||||
<label for="csv_file" class="form-label fw-bold">Select CSV File</label>
|
||||
<input type="file" class="form-control" id="csv_file" name="csv_file" accept=".csv" required>
|
||||
<div class="form-text mt-2">
|
||||
Max file size: 2MB. Only .csv files are allowed.
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 py-2">
|
||||
<i class="bi bi-upload me-2"></i>Upload and Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Instructions</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="small text-muted">
|
||||
<li>Download the CSV template using the button above.</li>
|
||||
<li>Fill in your supplier data. The <strong>name</strong> field is required.</li>
|
||||
<li>The <strong>type</strong> field should be either 'supplier' or 'contractor'.</li>
|
||||
<li>Save the file as a CSV and upload it.</li>
|
||||
</ol>
|
||||
<div class="alert alert-info py-2 small mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i> Existing suppliers with the same name will be added as new entries.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($results)): ?>
|
||||
<div class="card shadow-sm border-0 mt-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0">Import Logs</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush small">
|
||||
<?php foreach ($results as $res): ?>
|
||||
<div class="list-group-item px-0 border-0 py-1">
|
||||
<i class="bi bi-dot me-1"></i><?= $res ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include 'includes/footer.php'; ?>
|
||||
124
includes/auth_helper.php
Normal file
124
includes/auth_helper.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Configure session for iframed environments (like Flatlogic preview)
|
||||
*/
|
||||
function start_secure_session() {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||
|| ($_SERVER['SERVER_PORT'] == 443)
|
||||
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||
|
||||
// Use SameSite=None only if secure, otherwise use Lax
|
||||
$samesite = $secure ? 'None' : 'Lax';
|
||||
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => $samesite
|
||||
]);
|
||||
|
||||
if (!session_start()) {
|
||||
error_log("Failed to start session");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start_secure_session();
|
||||
|
||||
/**
|
||||
* Authentication Helper
|
||||
*/
|
||||
class Auth {
|
||||
public static function isLoggedIn(): bool {
|
||||
return isset($_SESSION['user_id']) && !empty($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
public static function requireLogin(): void {
|
||||
if (!self::isLoggedIn()) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public static function login(int $userId, int $tenantId, string $role): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
start_secure_session();
|
||||
}
|
||||
|
||||
$_SESSION['user_id'] = $userId;
|
||||
$_SESSION['tenant_id'] = $tenantId;
|
||||
$_SESSION['role'] = $role;
|
||||
|
||||
// Important: Save session before geolocation which might be slow
|
||||
session_write_close();
|
||||
|
||||
// Tracking
|
||||
$ip = self::getIpAddress();
|
||||
$country = self::getCountryFromIp($ip);
|
||||
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? null;
|
||||
|
||||
try {
|
||||
// Re-open to update tracking info in DB if we want,
|
||||
// but we can just use a fresh DB connection
|
||||
$stmt = db()->prepare("INSERT INTO user_sessions (user_id, ip_address, country, user_agent) VALUES (?, ?, ?, ?)");
|
||||
$stmt->execute([$userId, $ip, $country, $userAgent]);
|
||||
|
||||
$stmt = db()->prepare("UPDATE users SET last_login_at = NOW(), last_login_ip = ? WHERE id = ?");
|
||||
$stmt->execute([$ip, $userId]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log("Auth::login tracking error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function logout(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
start_secure_session();
|
||||
}
|
||||
$_SESSION = [];
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000,
|
||||
$params["path"], $params["domain"],
|
||||
$params["secure"], $params["httponly"]
|
||||
);
|
||||
session_destroy();
|
||||
header('Location: login.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
public static function getIpAddress(): string {
|
||||
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||
return $_SERVER['HTTP_CLIENT_IP'];
|
||||
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||
return explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])[0];
|
||||
} else {
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
public static function getCountryFromIp(string $ip): ?string {
|
||||
if ($ip === '127.0.0.1' || $ip === '::1') return 'Localhost';
|
||||
|
||||
try {
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 2]]);
|
||||
$resp = @file_get_contents("http://ip-api.com/json/{$ip}?fields=country", false, $ctx);
|
||||
if ($resp) {
|
||||
$data = json_decode($resp, true);
|
||||
return $data['country'] ?? 'Unknown';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
public static function recordResetAttempt(string $email, string $ip): void {
|
||||
try {
|
||||
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([0, 'Password Reset Attempt', "Email: $email, IP: $ip"]);
|
||||
} catch (\Throwable $e) {}
|
||||
}
|
||||
}
|
||||
35
includes/footer.php
Normal file
35
includes/footer.php
Normal file
@ -0,0 +1,35 @@
|
||||
|
||||
<footer class="py-4 mt-auto border-top bg-white">
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-center justify-content-between small text-muted">
|
||||
<div>© <?= date('Y') ?> SR&ED Manager ERP</div>
|
||||
<div>
|
||||
<a href="#" class="text-decoration-none text-muted me-3">Privacy Policy</a>
|
||||
<a href="#" class="text-decoration-none text-muted">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
// Smooth scroll for anchors
|
||||
document.querySelectorAll('a[href^="index.php#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
const url = new URL(this.href);
|
||||
if (window.location.pathname.endsWith('index.php') || window.location.pathname === '/') {
|
||||
e.preventDefault();
|
||||
const targetId = url.hash.substring(1);
|
||||
const targetElement = document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 80,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
159
includes/header.php
Normal file
159
includes/header.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* Global Header with Static Navigation
|
||||
*/
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= $pageTitle ?? 'SR&ED Manager' ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="assets/css/custom.css?v=<?= time() ?>" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="container-fluid py-3 bg-white">
|
||||
<a class="navbar-brand fs-3" href="index.php">SR&ED MANAGER</a>
|
||||
</div>
|
||||
|
||||
<nav class="navbar navbar-expand-lg sticky-top border-top">
|
||||
<div class="container-fluid">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $currentPage === 'index.php' ? 'active' : '' ?>" href="index.php">Dashboard</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $currentPage === 'projects.php' ? 'active' : '' ?>" href="projects.php">Projects</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle <?= in_array($currentPage, ['labour.php', 'labour_files.php']) ? 'active' : '' ?>" href="#" role="button" data-bs-toggle="dropdown">
|
||||
Labour
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="labour.php">Labour Tracking</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="labour_files.php">Labour Files</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle <?= in_array($currentPage, ['expenses.php', 'expense_files.php']) ? 'active' : '' ?>" href="#" role="button" data-bs-toggle="dropdown">
|
||||
Expenses
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="expenses.php">Expense Logs</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="expense_files.php">Expense Files</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $currentPage === 'employees.php' ? 'active' : '' ?>" href="employees.php">Employees</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle <?= in_array($currentPage, ['reports.php', 'files.php', 'reports_media.php']) ? 'active' : '' ?>" href="#" role="button" data-bs-toggle="dropdown">
|
||||
Reports
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="reports.php">Summary Reports</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="sred_claim_report_selector.php">SRED Claim Report</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="reports_media.php">Media Gallery</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="files.php">Files</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle <?= in_array($currentPage, ['settings.php', 'company_settings.php', 'system_preferences.php', 'import_suppliers.php', 'import_expenses.php', 'import_labour.php']) ? 'active' : '' ?>" href="#" role="button" data-bs-toggle="dropdown">
|
||||
Settings
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="settings.php">Datasets</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="company_settings.php">Company Preferences</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="system_preferences.php">System Preferences</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="import_suppliers.php">Import Suppliers</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="import_expenses.php">Import Expenses</a></li>
|
||||
<li><a class="dropdown-menu-item dropdown-item" href="import_labour.php">Import Labour</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="position-relative me-3" style="width: 250px;">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" id="globalSearch" class="form-control border-start-0 ps-0" placeholder="Search projects or employees..." autocomplete="off">
|
||||
</div>
|
||||
<div id="searchResults" class="dropdown-menu w-100 mt-1 shadow-sm" style="display: none; max-height: 300px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-light btn-sm border dropdown-toggle d-flex align-items-center" type="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
<div class="text-start me-2">
|
||||
<div class="extra-small fw-bold lh-1"><?= htmlspecialchars($_SESSION['role'] ?? 'User') ?></div>
|
||||
<div class="extra-small text-muted lh-1">Acme Research</div>
|
||||
</div>
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow-sm">
|
||||
<li><h6 class="dropdown-header small">Account Settings</h6></li>
|
||||
<li><a class="dropdown-item small" href="#"><i class="bi bi-person me-2"></i>My Profile</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item small text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i>Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('globalSearch');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
let debounceTimer;
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(debounceTimer);
|
||||
const query = this.value.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
searchResults.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
fetch(`api/search.php?q=${encodeURIComponent(query)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
searchResults.innerHTML = '';
|
||||
if (data.length > 0) {
|
||||
data.forEach(item => {
|
||||
const div = document.createElement('a');
|
||||
div.href = item.url;
|
||||
div.className = 'dropdown-item d-flex justify-content-between align-items-center';
|
||||
div.innerHTML = `
|
||||
<span>${item.label}</span>
|
||||
<span class="badge bg-light text-muted extra-small">${item.type}</span>
|
||||
`;
|
||||
searchResults.appendChild(div);
|
||||
});
|
||||
searchResults.style.display = 'block';
|
||||
} else {
|
||||
searchResults.innerHTML = '<div class="dropdown-item text-muted small">No results found</div>';
|
||||
searchResults.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) {
|
||||
searchResults.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
107
includes/media_helper.php
Normal file
107
includes/media_helper.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Creates a thumbnail for an image.
|
||||
*
|
||||
* @param string $sourcePath Path to the source image.
|
||||
* @param string $targetPath Path where the thumbnail should be saved.
|
||||
* @param int $maxWidth Maximum width of the thumbnail.
|
||||
* @param int $maxHeight Maximum height of the thumbnail.
|
||||
* @return bool True on success, false on failure.
|
||||
*/
|
||||
function createThumbnail(string $sourcePath, string $targetPath, int $maxWidth = 400, int $maxHeight = 400): bool {
|
||||
if (!file_exists($sourcePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$imageInfo = getimagesize($sourcePath);
|
||||
if ($imageInfo === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
list($width, $height, $type) = $imageInfo;
|
||||
$sourceImage = null;
|
||||
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$sourceImage = @imagecreatefromjpeg($sourcePath);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$sourceImage = @imagecreatefrompng($sourcePath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$sourceImage = @imagecreatefromgif($sourcePath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$sourceImage = @imagecreatefromwebp($sourcePath);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$sourceImage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculate dimensions
|
||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
||||
if ($ratio >= 1) {
|
||||
$newWidth = $width;
|
||||
$newHeight = $height;
|
||||
} else {
|
||||
$newWidth = (int)($width * $ratio);
|
||||
$newHeight = (int)($height * $ratio);
|
||||
}
|
||||
|
||||
$thumbnail = imagecreatetruecolor($newWidth, $newHeight);
|
||||
|
||||
// Handle transparency for PNG/GIF/WebP
|
||||
if ($type == IMAGETYPE_PNG || $type == IMAGETYPE_GIF || $type == IMAGETYPE_WEBP) {
|
||||
imagealphablending($thumbnail, false);
|
||||
imagesavealpha($thumbnail, true);
|
||||
$transparent = imagecolorallocatealpha($thumbnail, 255, 255, 255, 127);
|
||||
imagefilledrectangle($thumbnail, 0, 0, $newWidth, $newHeight, $transparent);
|
||||
}
|
||||
|
||||
imagecopyresampled($thumbnail, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
||||
|
||||
if (!is_dir(dirname($targetPath))) {
|
||||
mkdir(dirname($targetPath), 0775, true);
|
||||
}
|
||||
|
||||
$success = false;
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$success = imagejpeg($thumbnail, $targetPath, 85);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$success = imagepng($thumbnail, $targetPath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$success = imagegif($thumbnail, $targetPath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$success = imagewebp($thumbnail, $targetPath);
|
||||
break;
|
||||
}
|
||||
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($thumbnail);
|
||||
|
||||
return $success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mime type is an image.
|
||||
*/
|
||||
function isImage(string $mimeType): bool {
|
||||
return str_starts_with($mimeType, 'image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a mime type is a video.
|
||||
*/
|
||||
function isVideo(string $mimeType): bool {
|
||||
return str_starts_with($mimeType, 'video/');
|
||||
}
|
||||
441
index.php
441
index.php
@ -1,150 +1,301 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Fetch Highlights Data
|
||||
$projectHighlights = db()->prepare("
|
||||
SELECT p.*,
|
||||
CONCAT(e.first_name, ' ', e.last_name) as owner_name,
|
||||
COALESCE((SELECT SUM(hours) FROM labour_entries WHERE project_id = p.id), 0) as total_hours
|
||||
FROM projects p
|
||||
LEFT JOIN employees e ON p.owner_id = e.id
|
||||
WHERE p.tenant_id = ?
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$projectHighlights->execute([$tenant_id]);
|
||||
$projectList = $projectHighlights->fetchAll();
|
||||
|
||||
$labourHighlights = db()->prepare("
|
||||
SELECT le.*, p.name as project_name, e.name as employee_name
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
WHERE le.tenant_id = ?
|
||||
ORDER BY le.entry_date DESC, le.created_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$labourHighlights->execute([$tenant_id]);
|
||||
$labourList = $labourHighlights->fetchAll();
|
||||
|
||||
$expenseHighlights = db()->prepare("
|
||||
SELECT e.*, p.name as project_name, s.name as supplier_name
|
||||
FROM expenses e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
JOIN suppliers s ON e.supplier_id = s.id
|
||||
WHERE e.tenant_id = ?
|
||||
ORDER BY e.entry_date DESC, e.created_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$expenseHighlights->execute([$tenant_id]);
|
||||
$expenseList = $expenseHighlights->fetchAll();
|
||||
|
||||
$employeeHighlights = db()->prepare("
|
||||
SELECT e.*
|
||||
FROM employees e
|
||||
WHERE e.tenant_id = ?
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$employeeHighlights->execute([$tenant_id]);
|
||||
$employeeList = $employeeHighlights->fetchAll();
|
||||
|
||||
// Chart Data
|
||||
$chart_days = isset($_GET['chart_days']) ? (int)$_GET['chart_days'] : 7;
|
||||
if (!in_array($chart_days, [7, 14, 30])) $chart_days = 7;
|
||||
|
||||
$chartDataQuery = db()->prepare("
|
||||
SELECT entry_date, SUM(hours) as total_hours
|
||||
FROM labour_entries
|
||||
WHERE tenant_id = ? AND entry_date > DATE_SUB(CURRENT_DATE, INTERVAL ? DAY)
|
||||
GROUP BY entry_date
|
||||
ORDER BY entry_date ASC
|
||||
");
|
||||
$chartDataQuery->execute([$tenant_id, $chart_days]);
|
||||
$rawChartData = $chartDataQuery->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
$chartLabels = [];
|
||||
$chartValues = [];
|
||||
for ($i = $chart_days - 1; $i >= 0; $i--) {
|
||||
$date = date('Y-m-d', strtotime("-$i days"));
|
||||
$chartLabels[] = date('M d', strtotime($date));
|
||||
$chartValues[] = (float)($rawChartData[$date] ?? 0);
|
||||
}
|
||||
|
||||
$activities = db()->prepare("SELECT * FROM activity_log WHERE tenant_id = ? ORDER BY created_at DESC LIMIT 10");
|
||||
$activities->execute([$tenant_id]);
|
||||
$activityList = $activities->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Dashboard";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<?php endif; ?>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--bg-color-end: #2575fc;
|
||||
--text-color: #ffffff;
|
||||
--card-bg-color: rgba(255, 255, 255, 0.01);
|
||||
--card-border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
|
||||
animation: bg-pan 20s linear infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
@keyframes bg-pan {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 100% 100%; }
|
||||
}
|
||||
main {
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg-color);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.loader {
|
||||
margin: 1.25rem auto 1.25rem;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.25);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hint {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap; border: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
code {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
footer {
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
</div>
|
||||
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
|
||||
<p class="hint">This page will update automatically as the plan is implemented.</p>
|
||||
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Dashboard Chart Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
|
||||
<h5 class="mb-0 fw-bold">Labour Hours Overview</h5>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="?chart_days=7" class="btn btn-outline-primary <?= $chart_days == 7 ? 'active' : '' ?>">7 Days</a>
|
||||
<a href="?chart_days=14" class="btn btn-outline-primary <?= $chart_days == 14 ? 'active' : '' ?>">14 Days</a>
|
||||
<a href="?chart_days=30" class="btn btn-outline-primary <?= $chart_days == 30 ? 'active' : '' ?>">30 Days</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div style="height: 300px;">
|
||||
<canvas id="hoursChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
Page updated: <?= htmlspecialchars($now) ?> (UTC)
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Projects Highlight -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Projects</h6>
|
||||
<a href="projects.php" class="btn btn-sm btn-link text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="extra-small text-muted">
|
||||
<th>PROJECT</th>
|
||||
<th>STATUS</th>
|
||||
<th class="text-end">HOURS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-bold small"><?= htmlspecialchars($p['name']) ?></div>
|
||||
<div class="extra-small text-muted"><?= htmlspecialchars($p['code']) ?></div>
|
||||
</td>
|
||||
<td><span class="status-badge status-<?= str_replace('_', '-', $p['status']) ?>"><?= ucfirst($p['status']) ?></span></td>
|
||||
<td class="text-end fw-bold"><?= number_format((float)$p['total_hours'], 1) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Labour Highlights -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Labour</h6>
|
||||
<a href="labour.php" class="btn btn-sm btn-link text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="extra-small text-muted">
|
||||
<th>DATE</th>
|
||||
<th>EMPLOYEE</th>
|
||||
<th class="text-end">HOURS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($labourList as $l): ?>
|
||||
<tr>
|
||||
<td class="extra-small text-muted"><?= $l['entry_date'] ?></td>
|
||||
<td class="small fw-bold"><?= htmlspecialchars($l['employee_name']) ?></td>
|
||||
<td class="text-end"><span class="badge bg-light text-primary border"><?= number_format((float)$l['hours'], 1) ?>h</span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense Highlights -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Recent Expenses</h6>
|
||||
<a href="expenses.php" class="btn btn-sm btn-link text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="extra-small text-muted">
|
||||
<th>DATE</th>
|
||||
<th>SUPPLIER</th>
|
||||
<th class="text-end">AMOUNT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($expenseList as $ex): ?>
|
||||
<tr>
|
||||
<td class="extra-small text-muted"><?= $ex['entry_date'] ?></td>
|
||||
<td class="small fw-bold"><?= htmlspecialchars($ex['supplier_name']) ?></td>
|
||||
<td class="text-end fw-bold">$<?= number_format((float)$ex['amount'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Employee Highlights -->
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">New Employees</h6>
|
||||
<a href="employees.php" class="btn btn-sm btn-link text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="extra-small text-muted">
|
||||
<th>NAME</th>
|
||||
<th>POSITION</th>
|
||||
<th class="text-end">JOINED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<tr>
|
||||
<td class="small fw-bold"><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></td>
|
||||
<td class="extra-small text-muted"><?= htmlspecialchars($e['position']) ?></td>
|
||||
<td class="text-end extra-small text-muted"><?= $e['start_date'] ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Section -->
|
||||
<div class="row mt-4" id="activity-section">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">Recent Activity</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr class="extra-small text-muted">
|
||||
<th style="width: 200px;">TIME</th>
|
||||
<th>ACTION</th>
|
||||
<th>DETAILS</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($activityList as $a): ?>
|
||||
<tr>
|
||||
<td class="text-muted extra-small"><?= date('M d, Y H:i', strtotime($a['created_at'])) ?></td>
|
||||
<td><span class="badge bg-light text-primary border extra-small"><?= htmlspecialchars($a['action']) ?></span></td>
|
||||
<td class="small"><?= htmlspecialchars($a['details']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const ctx = document.getElementById('hoursChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: <?= json_encode($chartLabels) ?>,
|
||||
datasets: [{
|
||||
label: 'Labour Hours',
|
||||
data: <?= json_encode($chartValues) ?>,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: '#3b82f6'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { borderDash: [5, 5] } },
|
||||
x: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
|
||||
635
labour.php
Normal file
635
labour.php
Normal file
@ -0,0 +1,635 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
require_once __DIR__ . '/includes/media_helper.php';
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Bulk Labour
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['bulk_labour'])) {
|
||||
$project_id = (int)($_POST['bulk_project_id'] ?? 0);
|
||||
$employee_id = (int)($_POST['bulk_employee_id'] ?? 0);
|
||||
$labour_type_id = (int)($_POST['bulk_labour_type_id'] ?? 0);
|
||||
$evidence_type_id = (int)($_POST['bulk_evidence_type_id'] ?? 0);
|
||||
$notes = $_POST['bulk_notes'] ?? '';
|
||||
|
||||
$days = $_POST['bulk_days'] ?? []; // Array of date => hours
|
||||
|
||||
if ($project_id && $employee_id && !empty($days)) {
|
||||
$db = db();
|
||||
foreach ($days as $date => $hours) {
|
||||
$hours = (float)$hours;
|
||||
if ($hours <= 0) continue;
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO labour_entries (tenant_id, project_id, employee_id, entry_date, hours, labour_type_id, evidence_type_id, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $project_id, $employee_id, $date, $hours, $labour_type_id, $evidence_type_id, $notes]);
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, 'Bulk Labour Added', "Logged hours for employee ID $employee_id via bulk entry"]);
|
||||
|
||||
header("Location: labour.php?success=1");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_labour'])) {
|
||||
$project_id = (int)($_POST['project_id'] ?? 0);
|
||||
$employee_ids = isset($_POST['employee_ids']) ? array_map('intval', $_POST['employee_ids']) : [];
|
||||
if (empty($employee_ids) && isset($_POST['employee_id'])) {
|
||||
$employee_ids = [(int)$_POST['employee_id']];
|
||||
}
|
||||
|
||||
$entry_date = $_POST['entry_date'] ?? date('Y-m-d');
|
||||
$hours = (float)($_POST['hours'] ?? 0);
|
||||
$labour_type_id = (int)($_POST['labour_type_id'] ?? 0);
|
||||
$evidence_type_id = (int)($_POST['evidence_type_id'] ?? 0);
|
||||
$notes = $_POST['notes'] ?? '';
|
||||
|
||||
if ($project_id && !empty($employee_ids) && $hours > 0) {
|
||||
$db = db();
|
||||
foreach ($employee_ids as $employee_id) {
|
||||
$stmt = $db->prepare("INSERT INTO labour_entries (tenant_id, project_id, employee_id, entry_date, hours, labour_type_id, evidence_type_id, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $project_id, $employee_id, $entry_date, $hours, $labour_type_id, $evidence_type_id, $notes]);
|
||||
$labour_entry_id = (int)$db->lastInsertId();
|
||||
|
||||
// Handle File Uploads (only for the first one or all? Usually all if it's a team entry)
|
||||
if (!empty($_FILES['attachments']['name'][0])) {
|
||||
foreach ($_FILES['attachments']['tmp_name'] as $key => $tmp_name) {
|
||||
if ($_FILES['attachments']['error'][$key] === UPLOAD_ERR_OK) {
|
||||
$file_name = $_FILES['attachments']['name'][$key];
|
||||
$file_size = $_FILES['attachments']['size'][$key];
|
||||
$mime_type = $_FILES['attachments']['type'][$key];
|
||||
$file_ext = pathinfo($file_name, PATHINFO_EXTENSION);
|
||||
$new_file_name = uniqid() . '.' . $file_ext;
|
||||
$file_path = 'uploads/' . $new_file_name;
|
||||
|
||||
if (!is_dir('uploads')) mkdir('uploads', 0775, true);
|
||||
if (move_uploaded_file($tmp_name, $file_path)) {
|
||||
$thumbnail_path = null;
|
||||
if (isImage($mime_type)) {
|
||||
$thumb_name = 'thumb_' . $new_file_name;
|
||||
$thumb_path = 'uploads/' . $thumb_name;
|
||||
if (createThumbnail($file_path, $thumb_path)) {
|
||||
$thumbnail_path = $thumb_path;
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO attachments (tenant_id, entity_type, entity_id, file_name, file_path, thumbnail_path, file_size, mime_type, uploaded_by) VALUES (?, 'labour_entry', ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $labour_entry_id, $file_name, $file_path, $thumbnail_path, $file_size, $mime_type, $currentUserName]);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, 'Labour Added', "Logged $hours hours for " . count($employee_ids) . " employee(s)"]);
|
||||
|
||||
header("Location: labour.php?success=1");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch Data
|
||||
$filter_project = (int)($_GET['project_id'] ?? 0);
|
||||
$filter_employee = (int)($_GET['employee_id'] ?? 0);
|
||||
$filter_type = (int)($_GET['labour_type_id'] ?? 0);
|
||||
$filter_evidence = (int)($_GET['evidence_type_id'] ?? 0);
|
||||
$filter_start = $_GET['start_date'] ?? '';
|
||||
$filter_end = $_GET['end_date'] ?? '';
|
||||
|
||||
$where = ["le.tenant_id = ?"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if ($filter_project) {
|
||||
$where[] = "le.project_id = ?";
|
||||
$params[] = $filter_project;
|
||||
}
|
||||
if ($filter_employee) {
|
||||
$where[] = "le.employee_id = ?";
|
||||
$params[] = $filter_employee;
|
||||
}
|
||||
if ($filter_type) {
|
||||
$where[] = "le.labour_type_id = ?";
|
||||
$params[] = $filter_type;
|
||||
}
|
||||
if ($filter_evidence) {
|
||||
$where[] = "le.evidence_type_id = ?";
|
||||
$params[] = $filter_evidence;
|
||||
}
|
||||
if ($filter_start) {
|
||||
$where[] = "le.entry_date >= ?";
|
||||
$params[] = $filter_start;
|
||||
}
|
||||
if ($filter_end) {
|
||||
$where[] = "le.entry_date <= ?";
|
||||
$params[] = $filter_end;
|
||||
}
|
||||
|
||||
$where_clause = implode(" AND ", $where);
|
||||
|
||||
$labourEntries = db()->prepare("
|
||||
SELECT le.*, p.name as project_name, e.name as employee_name, lt.name as labour_type, et.name as evidence_type
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN labour_types lt ON le.labour_type_id = lt.id
|
||||
LEFT JOIN evidence_types et ON le.evidence_type_id = et.id
|
||||
WHERE $where_clause
|
||||
ORDER BY le.entry_date DESC, le.created_at DESC
|
||||
");
|
||||
$labourEntries->execute($params);
|
||||
$labourList = $labourEntries->fetchAll();
|
||||
|
||||
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? AND is_archived = 0 ORDER BY name");
|
||||
$projects->execute([$tenant_id]);
|
||||
$projectList = $projects->fetchAll();
|
||||
|
||||
$employees = db()->prepare("SELECT id, first_name, last_name FROM employees WHERE tenant_id = ? ORDER BY first_name, last_name");
|
||||
$employees->execute([$tenant_id]);
|
||||
$employeeList = $employees->fetchAll();
|
||||
|
||||
$labourTypes = db()->prepare("SELECT * FROM labour_types WHERE tenant_id = ? ORDER BY name");
|
||||
$labourTypes->execute([$tenant_id]);
|
||||
$labourTypeList = $labourTypes->fetchAll();
|
||||
|
||||
$evidenceTypes = db()->prepare("SELECT * FROM evidence_types WHERE tenant_id = ? ORDER BY name");
|
||||
$evidenceTypes->execute([$tenant_id]);
|
||||
$evidenceTypeList = $evidenceTypes->fetchAll();
|
||||
|
||||
// Find current employee (mocked as user_id 1)
|
||||
$currentUser = db()->prepare("SELECT name FROM users WHERE id = 1");
|
||||
$currentUser->execute();
|
||||
$currentUserName = $currentUser->fetchColumn() ?: 'System';
|
||||
|
||||
$currentEmployeeStmt = db()->prepare("SELECT id FROM employees WHERE user_id = 1 AND tenant_id = ?");
|
||||
$currentEmployeeStmt->execute([$tenant_id]);
|
||||
$currentEmployeeId = $currentEmployeeStmt->fetchColumn() ?: null;
|
||||
|
||||
$teams = db()->prepare("SELECT * FROM teams WHERE tenant_id = ? ORDER BY name");
|
||||
$teams->execute([$tenant_id]);
|
||||
$teamList = $teams->fetchAll();
|
||||
|
||||
$teamMembers = db()->prepare("SELECT * FROM employee_teams WHERE tenant_id = ?");
|
||||
$teamMembers->execute([$tenant_id]);
|
||||
$teamMemberList = $teamMembers->fetchAll();
|
||||
// Group by team
|
||||
$teamMembersGrouped = [];
|
||||
foreach ($teamMemberList as $tm) {
|
||||
$teamMembersGrouped[$tm['team_id']][] = $tm['employee_id'];
|
||||
}
|
||||
|
||||
$pageTitle = "SR&ED Manager - Labour Tracking";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Labour Tracking</h2>
|
||||
<div>
|
||||
<a href="api/export_labour.php?<?= http_build_query($_GET) ?>" class="btn btn-primary me-2"><i class="bi bi-file-earmark-excel me-1"></i> Export to Excel</a>
|
||||
<button class="btn btn-primary me-2" data-bs-toggle="modal" data-bs-target="#bulkLabourModal"><i class="bi bi-calendar3 me-1"></i> Bulk Add</button>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addLabourModal">+ Add Labour Entry</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select form-select-sm">
|
||||
<option value="">All Projects</option>
|
||||
<?php
|
||||
$allProjects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name");
|
||||
$allProjects->execute([$tenant_id]);
|
||||
foreach ($allProjects->fetchAll() as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $filter_project == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Employee</label>
|
||||
<select name="employee_id" class="form-select form-select-sm">
|
||||
<option value="">All Employees</option>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<option value="<?= $e['id'] ?>" <?= $filter_employee == $e['id'] ? 'selected' : '' ?>><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Labour Type</label>
|
||||
<select name="labour_type_id" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
<?php foreach ($labourTypeList as $lt): ?>
|
||||
<option value="<?= $lt['id'] ?>" <?= $filter_type == $lt['id'] ? 'selected' : '' ?>><?= htmlspecialchars($lt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Evidence</label>
|
||||
<select name="evidence_type_id" class="form-select form-select-sm">
|
||||
<option value="">All Evidence</option>
|
||||
<?php foreach ($evidenceTypeList as $et): ?>
|
||||
<option value="<?= $et['id'] ?>" <?= $filter_evidence == $et['id'] ? 'selected' : '' ?>><?= htmlspecialchars($et['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small fw-bold">From</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_start) ?>">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small fw-bold">To</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_end) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="d-flex gap-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1">Filter</button>
|
||||
<a href="labour.php" class="btn btn-sm btn-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Labour entry successfully saved.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Project</th>
|
||||
<th>Hours</th>
|
||||
<th>Type / Evidence</th>
|
||||
<th>Notes</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($labourList)): ?>
|
||||
<tr><td colspan="7" class="text-center py-5 text-muted">No labour entries found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($labourList as $l): ?>
|
||||
<tr>
|
||||
<td class="text-muted"><?= $l['entry_date'] ?></td>
|
||||
<td><strong><?= htmlspecialchars($l['employee_name']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($l['project_name']) ?></td>
|
||||
<td><span class="badge bg-light text-primary border"><?= number_format((float)$l['hours'], 2) ?> h</span></td>
|
||||
<td>
|
||||
<div class="small fw-bold"><?= htmlspecialchars($l['labour_type'] ?? 'N/A') ?></div>
|
||||
<div class="extra-small text-muted"><?= htmlspecialchars($l['evidence_type'] ?? 'N/A') ?></div>
|
||||
</td>
|
||||
<td class="small text-muted"><?= htmlspecialchars($l['notes'] ?? '') ?></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-secondary">Details</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="addLabourModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Add Labour Entry</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-12 mb-4">
|
||||
<label class="form-label small fw-bold d-block">Entry Mode</label>
|
||||
<div class="btn-group w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="entry_mode" id="modeIndividual" value="individual" checked autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="modeIndividual">Individual</label>
|
||||
|
||||
<input type="radio" class="btn-check" name="entry_mode" id="modeTeam" value="team" autocomplete="off">
|
||||
<label class="btn btn-outline-primary" for="modeTeam">Team</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 mb-3" id="individualSelect">
|
||||
<label class="form-label small fw-bold">Employee</label>
|
||||
<select name="employee_id" class="form-select">
|
||||
<option value="">Select Employee...</option>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<option value="<?= $e['id'] ?>" <?= $e['id'] == $currentEmployeeId ? 'selected' : '' ?>><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="teamSelect" style="display: none;" class="col-12">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold">Select Team</label>
|
||||
<select id="teamIdSelect" class="form-select mb-3">
|
||||
<option value="">Select Team...</option>
|
||||
<?php foreach ($teamList as $t): ?>
|
||||
<option value="<?= $t['id'] ?>"><?= htmlspecialchars($t['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold d-block">Team Members</label>
|
||||
<div id="teamMembersList" class="p-3 border rounded bg-light" style="max-height: 200px; overflow-y: auto;">
|
||||
<p class="text-muted small mb-0">Select a team to see members</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-12 mb-3">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select" required>
|
||||
<option value="">Select Project...</option>
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Date</label>
|
||||
<input type="date" name="entry_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Hours</label>
|
||||
<input type="number" name="hours" class="form-control" step="0.25" min="0" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Type of Labour</label>
|
||||
<select name="labour_type_id" class="form-select">
|
||||
<?php foreach ($labourTypeList as $lt): ?>
|
||||
<option value="<?= $lt['id'] ?>"><?= htmlspecialchars($lt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Objective Evidence</label>
|
||||
<select name="evidence_type_id" class="form-select">
|
||||
<?php foreach ($evidenceTypeList as $et): ?>
|
||||
<option value="<?= $et['id'] ?>"><?= htmlspecialchars($et['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 mb-3">
|
||||
<label class="form-label small fw-bold">Attachments</label>
|
||||
<input type="file" name="attachments[]" class="form-control" multiple>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small fw-bold">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" name="add_labour" class="btn btn-primary px-4">Save Entry</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Entry Modal -->
|
||||
<div class="modal fade" id="bulkLabourModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Bulk Labour Entry</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<div class="modal-body">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="fw-bold mb-0">Weekly Timesheet</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="week" id="bulkWeek" class="form-control form-control-sm" value="<?= date('Y-\WW') ?>">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered text-center">
|
||||
<thead class="bg-light">
|
||||
<tr id="bulkHeader">
|
||||
<!-- To be populated by JS -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bulkBody">
|
||||
<!-- To be populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="existingHoursInfo" class="small text-muted mt-2"></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="fw-bold mb-3">Labour Details</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Employee</label>
|
||||
<select name="bulk_employee_id" id="bulkEmployee" class="form-select form-select-sm" required>
|
||||
<option value="">Select Employee...</option>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<option value="<?= $e['id'] ?>" <?= $e['id'] == $currentEmployeeId ? 'selected' : '' ?>><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="bulk_project_id" id="bulkProject" class="form-select form-select-sm" required>
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<option value="<?= $p['id'] ?>"><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Labour Type</label>
|
||||
<select name="bulk_labour_type_id" class="form-select form-select-sm">
|
||||
<?php foreach ($labourTypeList as $lt): ?>
|
||||
<option value="<?= $lt['id'] ?>"><?= htmlspecialchars($lt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Evidence</label>
|
||||
<select name="bulk_evidence_type_id" class="form-select form-select-sm">
|
||||
<?php foreach ($evidenceTypeList as $et): ?>
|
||||
<option value="<?= $et['id'] ?>"><?= htmlspecialchars($et['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label small fw-bold">Notes</label>
|
||||
<textarea name="bulk_notes" class="form-control form-control-sm" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" name="bulk_labour" class="btn btn-primary px-4">Save All Entries</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Convert PHP arrays to JS for dynamic UI
|
||||
$employeesJson = json_encode($employeeList);
|
||||
$teamMembersJson = json_encode($teamMembersGrouped);
|
||||
?>
|
||||
<script>
|
||||
const employees = <?= $employeesJson ?>;
|
||||
const teamMembersGrouped = <?= $teamMembersJson ?>;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const modeIndividual = document.getElementById('modeIndividual');
|
||||
const modeTeam = document.getElementById('modeTeam');
|
||||
const individualSelect = document.getElementById('individualSelect');
|
||||
const teamSelect = document.getElementById('teamSelect');
|
||||
const teamIdSelect = document.getElementById('teamIdSelect');
|
||||
const teamMembersList = document.getElementById('teamMembersList');
|
||||
|
||||
function toggleMode() {
|
||||
if (modeIndividual.checked) {
|
||||
individualSelect.style.display = 'block';
|
||||
teamSelect.style.display = 'none';
|
||||
individualSelect.querySelector('select').required = true;
|
||||
} else {
|
||||
individualSelect.style.display = 'none';
|
||||
teamSelect.style.display = 'block';
|
||||
individualSelect.querySelector('select').required = false;
|
||||
}
|
||||
}
|
||||
|
||||
modeIndividual.addEventListener('change', toggleMode);
|
||||
modeTeam.addEventListener('change', toggleMode);
|
||||
|
||||
teamIdSelect.addEventListener('change', function() {
|
||||
const teamId = this.value;
|
||||
teamMembersList.innerHTML = '';
|
||||
|
||||
if (teamId && teamMembersGrouped[teamId]) {
|
||||
const members = teamMembersGrouped[teamId];
|
||||
members.forEach(empId => {
|
||||
const emp = employees.find(e => e.id == empId);
|
||||
if (emp) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'form-check';
|
||||
div.innerHTML = `
|
||||
<input class="form-check-input" type="checkbox" name="employee_ids[]" value="${emp.id}" id="emp_${emp.id}" checked>
|
||||
<label class="form-check-label" for="emp_${emp.id}">
|
||||
${emp.first_name} ${emp.last_name}
|
||||
</label>
|
||||
`;
|
||||
teamMembersList.appendChild(div);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
teamMembersList.innerHTML = '<p class="text-muted small mb-0">Select a team to see members</p>';
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk Entry Logic
|
||||
const bulkWeek = document.getElementById('bulkWeek');
|
||||
const bulkHeader = document.getElementById('bulkHeader');
|
||||
const bulkBody = document.getElementById('bulkBody');
|
||||
const bulkEmployee = document.getElementById('bulkEmployee');
|
||||
const existingHoursInfo = document.getElementById('existingHoursInfo');
|
||||
|
||||
function updateBulkView() {
|
||||
const weekStr = bulkWeek.value;
|
||||
if (!weekStr) return;
|
||||
|
||||
const [year, week] = weekStr.split('-W').map(Number);
|
||||
const startDate = getStartDateOfWeek(week, year);
|
||||
|
||||
bulkHeader.innerHTML = '';
|
||||
bulkBody.innerHTML = '<tr>';
|
||||
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const dates = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
dates.push(dateStr);
|
||||
|
||||
const th = document.createElement('th');
|
||||
th.innerHTML = `${days[i]}<br><small class="fw-normal text-muted">${d.getMonth() + 1}/${d.getDate()}</small>`;
|
||||
bulkHeader.appendChild(th);
|
||||
|
||||
const td = document.createElement('td');
|
||||
td.innerHTML = `
|
||||
<input type="number" name="bulk_days[${dateStr}]" class="form-control text-center bulk-hour-input" step="0.5" min="0" placeholder="0">
|
||||
<div class="extra-small text-muted mt-1 existing-hours" data-date="${dateStr}"></div>
|
||||
`;
|
||||
bulkBody.appendChild(td);
|
||||
}
|
||||
bulkBody.innerHTML += '</tr>';
|
||||
|
||||
fetchExistingHours(dates[0], dates[6]);
|
||||
}
|
||||
|
||||
function getStartDateOfWeek(w, y) {
|
||||
const d = new Date(y, 0, 1 + (w - 1) * 7);
|
||||
const dow = d.getDay();
|
||||
const ISOweekStart = d;
|
||||
if (dow <= 4)
|
||||
ISOweekStart.setDate(d.getDate() - d.getDay());
|
||||
else
|
||||
ISOweekStart.setDate(d.getDate() + 8 - d.getDay());
|
||||
return ISOweekStart;
|
||||
}
|
||||
|
||||
function fetchExistingHours(start, end) {
|
||||
const empId = bulkEmployee.value;
|
||||
if (!empId) return;
|
||||
|
||||
fetch(`api/get_labour_stats.php?employee_id=${empId}&start_date=${start}&end_date=${end}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
document.querySelectorAll('.existing-hours').forEach(el => {
|
||||
const date = el.dataset.date;
|
||||
const record = data.find(d => d.entry_date === date);
|
||||
if (record) {
|
||||
el.innerHTML = `Total: ${record.total_hours}h`;
|
||||
el.classList.add('text-primary', 'fw-bold');
|
||||
} else {
|
||||
el.innerHTML = 'Total: 0h';
|
||||
el.classList.remove('text-primary', 'fw-bold');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
bulkWeek.addEventListener('change', updateBulkView);
|
||||
bulkEmployee.addEventListener('change', updateBulkView);
|
||||
|
||||
// Initial update
|
||||
updateBulkView();
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
165
labour_files.php
Normal file
165
labour_files.php
Normal file
@ -0,0 +1,165 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Filters
|
||||
$project_filter = $_GET['project_id'] ?? '';
|
||||
$start_date = $_GET['start_date'] ?? '';
|
||||
$end_date = $_GET['end_date'] ?? '';
|
||||
$include_archived = isset($_GET['include_archived']) && $_GET['include_archived'] === '1';
|
||||
|
||||
// Get Projects for filter
|
||||
$projects_sql = "SELECT id, name FROM projects WHERE tenant_id = ?";
|
||||
if (!$include_archived) {
|
||||
$projects_sql .= " AND is_archived = 0";
|
||||
}
|
||||
$projects_sql .= " ORDER BY name ASC";
|
||||
$projects_stmt = db()->prepare($projects_sql);
|
||||
$projects_stmt->execute([$tenant_id]);
|
||||
$all_projects = $projects_stmt->fetchAll();
|
||||
|
||||
$where_clauses = ["a.tenant_id = ?", "a.entity_type = 'labour_entry'"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if (!$include_archived) {
|
||||
$where_clauses[] = "p.is_archived = 0";
|
||||
}
|
||||
|
||||
if ($project_filter) {
|
||||
$where_clauses[] = "le.project_id = ?";
|
||||
$params[] = $project_filter;
|
||||
}
|
||||
if ($start_date) {
|
||||
$where_clauses[] = "le.entry_date >= ?";
|
||||
$params[] = $start_date;
|
||||
}
|
||||
if ($end_date) {
|
||||
$where_clauses[] = "le.entry_date <= ?";
|
||||
$params[] = $end_date;
|
||||
}
|
||||
|
||||
$where_sql = implode(" AND ", $where_clauses);
|
||||
|
||||
// Fetch Labour Files
|
||||
$stmt = db()->prepare("
|
||||
SELECT a.*, le.entry_date, e.name as employee_name, p.name as project_name
|
||||
FROM attachments a
|
||||
JOIN labour_entries le ON a.entity_id = le.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
WHERE $where_sql
|
||||
ORDER BY a.created_at DESC
|
||||
");
|
||||
$stmt->execute($params);
|
||||
$files = $stmt->fetchAll();
|
||||
|
||||
function formatBytes($bytes, $precision = 2) {
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
$bytes /= pow(1024, $pow);
|
||||
return round($bytes, $precision) . ' ' . $units[$pow];
|
||||
}
|
||||
|
||||
$pageTitle = "SR&ED Manager - Labour Files";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Labour Files</h2>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select">
|
||||
<option value="">All Projects</option>
|
||||
<?php foreach ($all_projects as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $project_filter == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= htmlspecialchars($start_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control" value="<?= htmlspecialchars($end_date) ?>">
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center mt-4">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="include_archived" id="includeArchived" value="1" <?= $include_archived ? 'checked' : '' ?> onchange="this.form.submit()">
|
||||
<label class="form-check-label small" for="includeArchived">Include Archived</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<div class="d-grid w-100 gap-2 d-md-flex">
|
||||
<button type="submit" class="btn btn-primary">Filter</button>
|
||||
<a href="labour_files.php" class="btn btn-outline-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded By</th>
|
||||
<th>Created At</th>
|
||||
<th>Linked Entry</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($files)): ?>
|
||||
<tr><td colspan="6" class="text-center py-5 text-muted">No labour files found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($files as $f): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<i class="bi bi-file-earmark-text me-2 text-primary"></i>
|
||||
<strong><?= htmlspecialchars($f['file_name']) ?></strong>
|
||||
</td>
|
||||
<td><small class="text-muted"><?= formatBytes((int)$f['file_size']) ?></small></td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="bg-light rounded-circle p-1 me-2" style="width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-person small"></i>
|
||||
</div>
|
||||
<small><?= htmlspecialchars($f['uploaded_by'] ?? 'System') ?></small>
|
||||
</div>
|
||||
</td>
|
||||
<td class="small text-muted"><?= date('M j, Y H:i', strtotime($f['created_at'])) ?></td>
|
||||
<td>
|
||||
<div class="small">
|
||||
<span class="fw-bold text-dark"><?= $f['entry_date'] ?></span><br>
|
||||
<span class="text-muted"><?= htmlspecialchars($f['employee_name']) ?> - <?= htmlspecialchars($f['project_name']) ?></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="<?= htmlspecialchars($f['file_path']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
78
login.php
Normal file
78
login.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
ob_start();
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
|
||||
if (Auth::isLoggedIn()) {
|
||||
header('Location: index.php', true, 302);
|
||||
exit;
|
||||
}
|
||||
|
||||
$error = '';
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
|
||||
$stmt = db()->prepare("SELECT * FROM users WHERE email = ? LIMIT 1");
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user && password_verify($password, $user['password'])) {
|
||||
Auth::login((int)$user['id'], (int)$user['tenant_id'], (string)$user['role']);
|
||||
session_write_close();
|
||||
header('Location: index.php', true, 302);
|
||||
exit;
|
||||
} else {
|
||||
$error = 'Invalid email or password.';
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Login - SR&ED Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.login-card { width: 100%; max-width: 400px; border: none; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); }
|
||||
.btn-primary { background-color: #3b82f6; border: none; padding: 10px; font-weight: 600; }
|
||||
.btn-primary:hover { background-color: #2563eb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="fw-bold text-primary">SR&ED MANAGER</h3>
|
||||
<p class="text-muted small">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger small"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Email Address</label>
|
||||
<input type="email" name="email" class="form-control" placeholder="name@company.com" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between">
|
||||
<label class="form-label small fw-bold">Password</label>
|
||||
<a href="forgot_password.php" class="small text-decoration-none">Forgot?</a>
|
||||
</div>
|
||||
<input type="password" name="password" class="form-control" placeholder="••••••••" required>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="remember">
|
||||
<label class="form-check-label small" for="remember">Remember me</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 mb-3">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
4
logout.php
Normal file
4
logout.php
Normal file
@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::logout();
|
||||
187
project_detail.php
Normal file
187
project_detail.php
Normal file
@ -0,0 +1,187 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
|
||||
$id = $_GET['id'] ?? null;
|
||||
if (!$id) {
|
||||
header('Location: projects.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$db = db();
|
||||
$project = $db->prepare("SELECT * FROM projects WHERE id = ?");
|
||||
$project->execute([$id]);
|
||||
$project = $project->fetch();
|
||||
|
||||
if (!$project) {
|
||||
die("Project not found.");
|
||||
}
|
||||
|
||||
$pageTitle = "Project Detail: " . htmlspecialchars($project['name']);
|
||||
include __DIR__ . '/includes/header.php';
|
||||
|
||||
// Fetch recent labour
|
||||
$labourStmt = $db->prepare("
|
||||
SELECT l.*, e.name as employee_name, lt.name as labour_type
|
||||
FROM labour_entries l
|
||||
JOIN employees e ON l.employee_id = e.id
|
||||
JOIN labour_types lt ON l.labour_type_id = lt.id
|
||||
WHERE l.project_id = ?
|
||||
ORDER BY l.entry_date DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$labourStmt->execute([$id]);
|
||||
$labourEntries = $labourStmt->fetchAll();
|
||||
|
||||
// Fetch recent expenses
|
||||
$expenseStmt = $db->prepare("
|
||||
SELECT ex.*, et.name as expense_type, s.name as supplier_name
|
||||
FROM expenses ex
|
||||
JOIN expense_types et ON ex.expense_type_id = et.id
|
||||
LEFT JOIN suppliers s ON ex.supplier_id = s.id
|
||||
WHERE ex.project_id = ?
|
||||
ORDER BY ex.entry_date DESC
|
||||
LIMIT 10
|
||||
");
|
||||
$expenseStmt->execute([$id]);
|
||||
$expenseEntries = $expenseStmt->fetchAll();
|
||||
|
||||
// Stats
|
||||
$statsStmt = $db->prepare("
|
||||
SELECT
|
||||
(SELECT SUM(hours) FROM labour_entries WHERE project_id = ?) as total_hours,
|
||||
(SELECT SUM(amount) FROM expenses WHERE project_id = ?) as total_expenses
|
||||
");
|
||||
$statsStmt->execute([$id, $id]);
|
||||
$stats = $statsStmt->fetch();
|
||||
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a href="projects.php" class="text-decoration-none">Projects</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page"><?= htmlspecialchars($project['name']) ?></li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h1 class="h3 mb-0"><?= htmlspecialchars($project['name']) ?> (<?= htmlspecialchars($project['code']) ?>)</h1>
|
||||
<span class="badge status-<?= $project['status'] ?>"><?= ucfirst($project['status']) ?></span>
|
||||
<?php if ($project['is_archived']): ?>
|
||||
<span class="badge bg-secondary">Archived</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div>
|
||||
<?php if ($project['is_archived']): ?>
|
||||
<a href="projects.php?unarchive=<?= $id ?>" class="btn btn-outline-success btn-sm me-2" onclick="return confirm('Unarchive this project?')"><i class="bi bi-arrow-up-circle me-1"></i> Unarchive</a>
|
||||
<?php else: ?>
|
||||
<a href="projects.php?archive=<?= $id ?>" class="btn btn-outline-danger btn-sm me-2" onclick="return confirm('Archive this project?')"><i class="bi bi-archive me-1"></i> Archive Project</a>
|
||||
<?php endif; ?>
|
||||
<button class="btn btn-outline-primary btn-sm me-2"><i class="bi bi-pencil me-1"></i> Edit Project</button>
|
||||
<?php if (!$project['is_archived']): ?>
|
||||
<a href="labour.php?project_id=<?= $id ?>" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg me-1"></i> Add Labour</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Total Hours</h6>
|
||||
<h3 class="mb-0 text-primary"><?= number_format($stats['total_hours'] ?? 0, 1) ?></h3>
|
||||
<small class="text-muted">Budget: <?= number_format($project['estimated_hours'] ?? 0) ?></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Total Expenses</h6>
|
||||
<h3 class="mb-0 text-success">$<?= number_format($stats['total_expenses'] ?? 0, 2) ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Type</h6>
|
||||
<h3 class="mb-0 text-dark"><?= $project['type'] ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted mb-2">Start Date</h6>
|
||||
<h3 class="mb-0 text-dark"><?= date('M j, Y', strtotime($project['start_date'])) ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Recent Labour</span>
|
||||
<a href="labour.php?project_id=<?= $id ?>" class="small text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($labourEntries as $le): ?>
|
||||
<tr>
|
||||
<td><?= date('Y-m-d', strtotime($le['entry_date'])) ?></td>
|
||||
<td><?= htmlspecialchars($le['employee_name']) ?></td>
|
||||
<td class="fw-bold text-primary"><?= number_format($le['hours'], 1) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Recent Expenses</span>
|
||||
<a href="expenses.php?project_id=<?= $id ?>" class="small text-decoration-none">View All</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Type</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($expenseEntries as $ee): ?>
|
||||
<tr>
|
||||
<td><?= date('Y-m-d', strtotime($ee['entry_date'])) ?></td>
|
||||
<td><?= htmlspecialchars($ee['expense_type']) ?></td>
|
||||
<td class="fw-bold text-success">$<?= number_format($ee['amount'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
319
projects.php
Normal file
319
projects.php
Normal file
@ -0,0 +1,319 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Add Project
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_project'])) {
|
||||
$name = $_POST['name'] ?? '';
|
||||
$code = $_POST['code'] ?? '';
|
||||
$start_date = $_POST['start_date'] ?? date('Y-m-d');
|
||||
$owner_id = !empty($_POST['owner_id']) ? (int)$_POST['owner_id'] : null;
|
||||
$est_completion = !empty($_POST['estimated_completion_date']) ? $_POST['estimated_completion_date'] : null;
|
||||
$type = $_POST['type'] ?? 'Internal';
|
||||
$est_hours = (float)($_POST['estimated_hours'] ?? 0);
|
||||
|
||||
if ($name && $code) {
|
||||
$stmt = db()->prepare("INSERT INTO projects (tenant_id, name, code, start_date, owner_id, estimated_completion_date, type, estimated_hours) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, $name, $code, $start_date, $owner_id, $est_completion, $type, $est_hours]);
|
||||
|
||||
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([$tenant_id, 'Project Created', "Added project: $name ($code)"]);
|
||||
|
||||
header("Location: projects.php?success=1");
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Archive/Unarchive
|
||||
if (isset($_GET['archive'])) {
|
||||
$id = (int)$_GET['archive'];
|
||||
$stmt = db()->prepare("UPDATE projects SET is_archived = 1 WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
header("Location: projects.php?archived=1");
|
||||
exit;
|
||||
}
|
||||
if (isset($_GET['unarchive'])) {
|
||||
$id = (int)$_GET['unarchive'];
|
||||
$stmt = db()->prepare("UPDATE projects SET is_archived = 0 WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
header("Location: projects.php?unarchived=1");
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fetch Data
|
||||
$search = $_GET['search'] ?? '';
|
||||
$status_filter = $_GET['status'] ?? '';
|
||||
$date_preset = $_GET['date_preset'] ?? '';
|
||||
$start_from = $_GET['start_from'] ?? '';
|
||||
$start_to = $_GET['start_to'] ?? '';
|
||||
$include_archived = isset($_GET['include_archived']) && $_GET['include_archived'] === '1';
|
||||
|
||||
$query = "
|
||||
SELECT p.*,
|
||||
CONCAT(e.first_name, ' ', e.last_name) as owner_name,
|
||||
COALESCE((SELECT SUM(hours) FROM labour_entries WHERE project_id = p.id), 0) as total_hours
|
||||
FROM projects p
|
||||
LEFT JOIN employees e ON p.owner_id = e.id
|
||||
WHERE p.tenant_id = ?";
|
||||
$params = [$tenant_id];
|
||||
|
||||
if (!$include_archived) {
|
||||
$query .= " AND p.is_archived = 0";
|
||||
}
|
||||
|
||||
if ($search) {
|
||||
$query .= " AND (p.name LIKE ? OR p.code LIKE ?)";
|
||||
$params[] = "%$search%";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
|
||||
if ($status_filter) {
|
||||
$query .= " AND p.status = ?";
|
||||
$params[] = $status_filter;
|
||||
}
|
||||
|
||||
if ($date_preset && $date_preset !== 'custom') {
|
||||
switch ($date_preset) {
|
||||
case 'today': $query .= " AND p.start_date = CURRENT_DATE"; break;
|
||||
case 'this_week': $query .= " AND p.start_date >= DATE_SUB(CURRENT_DATE, INTERVAL WEEKDAY(CURRENT_DATE) DAY)"; break;
|
||||
case 'last_week': $query .= " AND p.start_date >= DATE_SUB(CURRENT_DATE, INTERVAL WEEKDAY(CURRENT_DATE) + 7 DAY) AND p.start_date < DATE_SUB(CURRENT_DATE, INTERVAL WEEKDAY(CURRENT_DATE) DAY)"; break;
|
||||
case 'this_month': $query .= " AND p.start_date >= DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')"; break;
|
||||
case 'last_month': $query .= " AND p.start_date >= DATE_FORMAT(DATE_SUB(CURRENT_DATE, INTERVAL 1 MONTH), '%Y-%m-01') AND p.start_date < DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')"; break;
|
||||
case 'this_year': $query .= " AND p.start_date >= DATE_FORMAT(CURRENT_DATE, '%Y-01-01')"; break;
|
||||
case 'last_year': $query .= " AND p.start_date >= DATE_FORMAT(DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR), '%Y-01-01') AND p.start_date < DATE_FORMAT(CURRENT_DATE, '%Y-01-01')"; break;
|
||||
}
|
||||
} elseif ($date_preset === 'custom' && $start_from && $start_to) {
|
||||
$query .= " AND p.start_date BETWEEN ? AND ?";
|
||||
$params[] = $start_from;
|
||||
$params[] = $start_to;
|
||||
}
|
||||
|
||||
$query .= " ORDER BY p.created_at DESC";
|
||||
$projects = db()->prepare($query);
|
||||
$projects->execute($params);
|
||||
$projectList = $projects->fetchAll();
|
||||
|
||||
$employees = db()->prepare("SELECT id, first_name, last_name FROM employees WHERE tenant_id = ? ORDER BY first_name, last_name");
|
||||
$employees->execute([$tenant_id]);
|
||||
$employeeList = $employees->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Projects";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">Projects</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProjectModal">+ New Project</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Project successfully created.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['archived'])): ?>
|
||||
<div class="alert alert-info alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Project successfully archived.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['unarchived'])): ?>
|
||||
<div class="alert alert-success alert-dismissible fade show border-0 shadow-sm mb-4" role="alert">
|
||||
Project successfully unarchived.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Search</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="search" class="form-control" placeholder="Project name or code..." value="<?= htmlspecialchars($search) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="active" <?= $status_filter === 'active' ? 'selected' : '' ?>>Active</option>
|
||||
<option value="on_hold" <?= $status_filter === 'on_hold' ? 'selected' : '' ?>>On Hold</option>
|
||||
<option value="completed" <?= $status_filter === 'completed' ? 'selected' : '' ?>>Completed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<div class="d-flex gap-2">
|
||||
<select name="date_preset" class="form-select form-select-sm" onchange="handleDatePreset(this.value)" style="width: 140px;">
|
||||
<option value="">Any Time</option>
|
||||
<option value="this_month" <?= $date_preset === 'this_month' ? 'selected' : '' ?>>This Month</option>
|
||||
<option value="this_year" <?= $date_preset === 'this_year' ? 'selected' : '' ?>>This Year</option>
|
||||
<option value="custom" <?= $date_preset === 'custom' ? 'selected' : '' ?>>Custom...</option>
|
||||
</select>
|
||||
<div id="customDateRange" class="d-flex gap-1 <?= $date_preset === 'custom' ? '' : 'd-none' ?>">
|
||||
<input type="date" name="start_from" class="form-control form-control-sm" value="<?= htmlspecialchars($start_from) ?>" placeholder="From">
|
||||
<input type="date" name="start_to" class="form-control form-control-sm" value="<?= htmlspecialchars($start_to) ?>" placeholder="To">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check form-switch mb-1">
|
||||
<input class="form-check-input" type="checkbox" name="include_archived" id="includeArchived" value="1" <?= $include_archived ? 'checked' : '' ?>>
|
||||
<label class="form-check-label small fw-bold" for="includeArchived">Archived</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 text-end">
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1">Filter</button>
|
||||
<a href="projects.php" class="btn btn-sm btn-secondary">Reset</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row position-relative">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Project Name</th>
|
||||
<th>Owner</th>
|
||||
<th>Type</th>
|
||||
<th>Hours (Logged/Est)</th>
|
||||
<th>Variance</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($projectList)): ?>
|
||||
<tr><td colspan="7" class="text-center py-5 text-muted">No projects found.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($projectList as $p):
|
||||
$total_hours = (float)$p['total_hours'];
|
||||
$est_hours = (float)$p['estimated_hours'];
|
||||
$variance = $est_hours - $total_hours;
|
||||
$is_over = $total_hours > $est_hours && $est_hours > 0;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<strong><a href="project_detail.php?id=<?= $p['id'] ?>" class="text-decoration-none text-dark"><?= htmlspecialchars($p['name']) ?></a></strong>
|
||||
<?php if ($p['is_archived']): ?>
|
||||
<span class="badge bg-secondary extra-small ms-1">Archived</span>
|
||||
<?php endif; ?><br>
|
||||
<code class="extra-small text-primary"><?= htmlspecialchars($p['code']) ?></code>
|
||||
</td>
|
||||
<td><small><?= htmlspecialchars($p['owner_name'] ?: 'Unassigned') ?></small></td>
|
||||
<td><span class="badge bg-light text-dark border"><?= $p['type'] ?></span></td>
|
||||
<td>
|
||||
<span class="fw-bold <?= $is_over ? 'text-danger' : '' ?>"><?= number_format($total_hours, 1) ?></span>
|
||||
<span class="text-muted">/ <?= number_format($est_hours, 1) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="fw-bold <?= $is_over ? 'text-danger' : 'text-success' ?>">
|
||||
<?= ($variance >= 0 ? '+' : '') . number_format($variance, 1) ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="status-badge status-<?= str_replace('_', '-', $p['status']) ?>"><?= ucfirst(str_replace('_', ' ', $p['status'])) ?></span></td>
|
||||
<td class="text-end">
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">Actions</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="project_detail.php?id=<?= $p['id'] ?>">View Details</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<?php if ($p['is_archived']): ?>
|
||||
<li><a class="dropdown-item" href="projects.php?unarchive=<?= $p['id'] ?>" onclick="return confirm('Unarchive this project?')">Unarchive</a></li>
|
||||
<?php else: ?>
|
||||
<li><a class="dropdown-item text-danger" href="projects.php?archive=<?= $p['id'] ?>" onclick="return confirm('Are you sure you want to archive this project? Future hours and expenses will be limited.')">Archive</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="addProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title fw-bold">Add New Project</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form method="POST">
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label class="form-label small fw-bold">Project Name</label>
|
||||
<input type="text" name="name" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label small fw-bold">Project Code</label>
|
||||
<input type="text" name="code" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Project Owner</label>
|
||||
<select name="owner_id" class="form-select">
|
||||
<option value="">Select Owner...</option>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<option value="<?= $e['id'] ?>"><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Project Type</label>
|
||||
<select name="type" class="form-select">
|
||||
<option value="Internal">Internal</option>
|
||||
<option value="SRED">SR&ED</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control" value="<?= date('Y-m-d') ?>">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label small fw-bold">Estimated Hours</label>
|
||||
<input type="number" name="estimated_hours" class="form-control" step="0.5" min="0" value="0">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="submit" name="add_project" class="btn btn-primary px-4">Create Project</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function handleDatePreset(val) {
|
||||
const custom = document.getElementById('customDateRange');
|
||||
if (val === 'custom') {
|
||||
custom.classList.remove('d-none');
|
||||
} else {
|
||||
custom.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
339
reports.php
Normal file
339
reports.php
Normal file
@ -0,0 +1,339 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
// Simulate Tenant Context
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
$report_type = $_GET['report_type'] ?? 'labour_export';
|
||||
|
||||
// Filter Data for Export Report
|
||||
$f_employee = $_GET['employee_id'] ?? '';
|
||||
$f_team = $_GET['team_id'] ?? '';
|
||||
$f_start = $_GET['start_date'] ?? date('Y-m-01');
|
||||
$f_end = $_GET['end_date'] ?? date('Y-m-t');
|
||||
$f_projects = $_GET['projects'] ?? []; // Array
|
||||
$f_activity = $_GET['activity_type_id'] ?? '';
|
||||
|
||||
// Handle Results for Labour Export
|
||||
$exportResults = [];
|
||||
$chartData = [];
|
||||
|
||||
if ($report_type === 'labour_export') {
|
||||
$query = "
|
||||
SELECT le.*, p.name as project_name, e.name as employee_name, lt.name as labour_type
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN labour_types lt ON le.labour_type_id = lt.id
|
||||
WHERE le.tenant_id = ? AND le.entry_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$tenant_id, $f_start, $f_end];
|
||||
|
||||
if ($f_employee) {
|
||||
$query .= " AND le.employee_id = ?";
|
||||
$params[] = $f_employee;
|
||||
}
|
||||
|
||||
if ($f_team) {
|
||||
$query .= " AND le.employee_id IN (SELECT employee_id FROM employee_teams WHERE team_id = ?)";
|
||||
$params[] = $f_team;
|
||||
}
|
||||
|
||||
if (!empty($f_projects)) {
|
||||
$placeholders = implode(',', array_fill(0, count($f_projects), '?'));
|
||||
$query .= " AND le.project_id IN ($placeholders)";
|
||||
foreach ($f_projects as $pid) $params[] = $pid;
|
||||
}
|
||||
|
||||
if ($f_activity) {
|
||||
$query .= " AND le.labour_type_id = ?";
|
||||
$params[] = $f_activity;
|
||||
}
|
||||
|
||||
$query .= " ORDER BY le.entry_date ASC";
|
||||
$stmt = db()->prepare($query);
|
||||
$stmt->execute($params);
|
||||
$exportResults = $stmt->fetchAll();
|
||||
|
||||
// Group for Chart (Hours per Day)
|
||||
$grouped = [];
|
||||
foreach ($exportResults as $row) {
|
||||
$grouped[$row['entry_date']] = ($grouped[$row['entry_date']] ?? 0) + (float)$row['hours'];
|
||||
}
|
||||
ksort($grouped);
|
||||
$chartData = [
|
||||
'labels' => array_keys($grouped),
|
||||
'values' => array_values($grouped)
|
||||
];
|
||||
}
|
||||
|
||||
// Handle Calendar Report
|
||||
$cal_month = $_GET['month'] ?? date('Y-m');
|
||||
$calendarData = [];
|
||||
if ($report_type === 'calendar') {
|
||||
$stmt = db()->prepare("
|
||||
SELECT entry_date, SUM(hours) as total_hours
|
||||
FROM labour_entries
|
||||
WHERE tenant_id = ? AND DATE_FORMAT(entry_date, '%Y-%m') = ?
|
||||
GROUP BY entry_date
|
||||
");
|
||||
$stmt->execute([$tenant_id, $cal_month]);
|
||||
$calendarData = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
}
|
||||
|
||||
// Fetch helper data
|
||||
$employees = db()->prepare("SELECT id, name FROM employees WHERE tenant_id = ? ORDER BY name");
|
||||
$employees->execute([$tenant_id]);
|
||||
$employeeList = $employees->fetchAll();
|
||||
|
||||
$teams = db()->prepare("SELECT id, name FROM teams WHERE tenant_id = ? ORDER BY name");
|
||||
$teams->execute([$tenant_id]);
|
||||
$teamList = $teams->fetchAll();
|
||||
|
||||
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name");
|
||||
$projects->execute([$tenant_id]);
|
||||
$projectList = $projects->fetchAll();
|
||||
|
||||
$labourTypes = db()->prepare("SELECT id, name FROM labour_types WHERE tenant_id = ? ORDER BY name");
|
||||
$labourTypes->execute([$tenant_id]);
|
||||
$labourTypeList = $labourTypes->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Reports";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<ul class="nav nav-pills mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $report_type === 'labour_export' ? 'active' : '' ?>" href="reports.php?report_type=labour_export">Labour Export</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link <?= $report_type === 'calendar' ? 'active' : '' ?>" href="reports.php?report_type=calendar">Monthly Calendar</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="sred_claim_report_selector.php">SRED Claim Report</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="reports_media.php">Media Gallery</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<?php if ($report_type === 'labour_export'): ?>
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
<input type="hidden" name="report_type" value="labour_export">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Employee</label>
|
||||
<select name="employee_id" class="form-select form-select-sm">
|
||||
<option value="">All Employees</option>
|
||||
<?php foreach ($employeeList as $e): ?>
|
||||
<option value="<?= $e['id'] ?>" <?= $f_employee == $e['id'] ? 'selected' : '' ?>><?= htmlspecialchars($e['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Team</label>
|
||||
<select name="team_id" class="form-select form-select-sm">
|
||||
<option value="">All Teams</option>
|
||||
<?php foreach ($teamList as $t): ?>
|
||||
<option value="<?= $t['id'] ?>" <?= $f_team == $t['id'] ? 'selected' : '' ?>><?= htmlspecialchars($t['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Start Date</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="<?= $f_start ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">End Date</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="<?= $f_end ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Projects (Multi-select)</label>
|
||||
<select name="projects[]" class="form-select form-select-sm" multiple style="height: 100px;">
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= in_array($p['id'], $f_projects) ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="extra-small text-muted">Hold Ctrl/Cmd to select multiple</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Activity Type</label>
|
||||
<select name="activity_type_id" class="form-select form-select-sm">
|
||||
<option value="">All Activities</option>
|
||||
<?php foreach ($labourTypeList as $lt): ?>
|
||||
<option value="<?= $lt['id'] ?>" <?= $f_activity == $lt['id'] ? 'selected' : '' ?>><?= htmlspecialchars($lt['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Generate Report</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
<input type="hidden" name="report_type" value="calendar">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Select Month</label>
|
||||
<input type="month" name="month" class="form-control form-control-sm" value="<?= $cal_month ?>">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">View Calendar</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($report_type === 'labour_export'): ?>
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Detailed Labour Records</h6>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-secondary" onclick="window.print()"><i class="bi bi-printer"></i> Print</button>
|
||||
<a href="api/export_labour.php?<?= http_build_query($_GET) ?>" class="btn btn-sm btn-primary"><i class="bi bi-file-earmark-excel"></i> Export to Excel</a>
|
||||
<a href="export_pdf.php?<?= http_build_query($_GET) ?>" class="btn btn-sm btn-primary ms-1"><i class="bi bi-file-earmark-pdf"></i> Download PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Project</th>
|
||||
<th>Activity</th>
|
||||
<th class="text-end">Hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($exportResults)): ?>
|
||||
<tr><td colspan="5" class="text-center py-5 text-muted">No records found for the selected criteria.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php $total = 0; foreach ($exportResults as $row): $total += (float)$row['hours']; ?>
|
||||
<tr>
|
||||
<td><?= $row['entry_date'] ?></td>
|
||||
<td><strong><?= htmlspecialchars($row['employee_name']) ?></strong></td>
|
||||
<td><?= htmlspecialchars($row['project_name']) ?></td>
|
||||
<td><span class="badge bg-light text-dark border"><?= htmlspecialchars($row['labour_type'] ?? 'Uncategorized') ?></span></td>
|
||||
<td class="text-end fw-bold"><?= number_format((float)$row['hours'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<?php if (!empty($exportResults)): ?>
|
||||
<tfoot class="bg-light">
|
||||
<tr>
|
||||
<td colspan="4" class="text-end fw-bold">Total Hours:</td>
|
||||
<td class="text-end fw-bold text-primary"><?= number_format($total, 2) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<h6 class="mb-0 fw-bold">Hours Breakdown</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="exportChart" style="height: 300px;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ctx = document.getElementById('exportChart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: <?= json_encode($chartData['labels']) ?>,
|
||||
datasets: [{
|
||||
label: 'Hours',
|
||||
data: <?= json_encode($chartData['values']) ?>,
|
||||
backgroundColor: 'rgba(13, 110, 253, 0.2)',
|
||||
borderColor: '#0d6efd',
|
||||
borderWidth: 2,
|
||||
borderRadius: 5
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, grid: { display: false } },
|
||||
x: { grid: { display: false } }
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php elseif ($report_type === 'calendar'): ?>
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0 fw-bold">Monthly Labour Calendar - <?= date('F Y', strtotime($cal_month)) ?></h6>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-secondary" onclick="window.print()"><i class="bi bi-printer"></i> Print</button>
|
||||
<a href="export_pdf.php?<?= http_build_query($_GET) ?>" class="btn btn-sm btn-primary"><i class="bi bi-file-earmark-pdf"></i> Download PDF</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-header-day">Sun</div>
|
||||
<div class="calendar-header-day">Mon</div>
|
||||
<div class="calendar-header-day">Tue</div>
|
||||
<div class="calendar-header-day">Wed</div>
|
||||
<div class="calendar-header-day">Thu</div>
|
||||
<div class="calendar-header-day">Fri</div>
|
||||
<div class="calendar-header-day">Sat</div>
|
||||
|
||||
<?php
|
||||
$first_day = date('Y-m-01', strtotime($cal_month));
|
||||
$days_in_month = date('t', strtotime($cal_month));
|
||||
$start_day_of_week = (int)date('w', strtotime($first_day));
|
||||
|
||||
// Fill empty days before first of month
|
||||
for ($i = 0; $i < $start_day_of_week; $i++) {
|
||||
echo '<div class="calendar-day other-month"></div>';
|
||||
}
|
||||
|
||||
// Days of month
|
||||
for ($day = 1; $day <= $days_in_month; $day++) {
|
||||
$current_date = date('Y-m-', strtotime($cal_month)) . str_pad((string)$day, 2, '0', STR_PAD_LEFT);
|
||||
$hours = $calendarData[$current_date] ?? 0;
|
||||
?>
|
||||
<div class="calendar-day">
|
||||
<span class="day-number"><?= $day ?></span>
|
||||
<?php if ($hours > 0): ?>
|
||||
<span class="day-hours"><?= number_format((float)$hours, 1) ?><small class="ms-1 h6 mb-0" style="font-size: 0.6rem;">h</small></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
// Fill remaining days to complete grid
|
||||
$total_cells = ceil(($start_day_of_week + $days_in_month) / 7) * 7;
|
||||
for ($i = ($start_day_of_week + $days_in_month); $i < $total_cells; $i++) {
|
||||
echo '<div class="calendar-day other-month"></div>';
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
228
reports_media.php
Normal file
228
reports_media.php
Normal file
@ -0,0 +1,228 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
require_once __DIR__ . '/includes/media_helper.php';
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Filters
|
||||
$filter_author = $_GET['author'] ?? '';
|
||||
$filter_project = (int)($_GET['project_id'] ?? 0);
|
||||
$filter_evidence = (int)($_GET['evidence_type_id'] ?? 0);
|
||||
$filter_start = $_GET['start_date'] ?? '';
|
||||
$filter_end = $_GET['end_date'] ?? '';
|
||||
|
||||
$where = ["a.tenant_id = ?", "(a.mime_type LIKE 'image/%' OR a.mime_type LIKE 'video/%')"];
|
||||
$params = [$tenant_id];
|
||||
|
||||
if ($filter_author) {
|
||||
$where[] = "a.uploaded_by = ?";
|
||||
$params[] = $filter_author;
|
||||
}
|
||||
if ($filter_project) {
|
||||
$where[] = "COALESCE(le.project_id, ex.project_id) = ?";
|
||||
$params[] = $filter_project;
|
||||
}
|
||||
if ($filter_evidence) {
|
||||
$where[] = "le.evidence_type_id = ?";
|
||||
$params[] = $filter_evidence;
|
||||
}
|
||||
if ($filter_start) {
|
||||
$where[] = "DATE(a.created_at) >= ?";
|
||||
$params[] = $filter_start;
|
||||
}
|
||||
if ($filter_end) {
|
||||
$where[] = "DATE(a.created_at) <= ?";
|
||||
$params[] = $filter_end;
|
||||
}
|
||||
|
||||
$where_clause = implode(" AND ", $where);
|
||||
|
||||
$query = "
|
||||
SELECT a.*,
|
||||
p.name as project_name,
|
||||
et.name as evidence_type_name
|
||||
FROM attachments a
|
||||
LEFT JOIN labour_entries le ON a.entity_type = 'labour_entry' AND a.entity_id = le.id
|
||||
LEFT JOIN expenses ex ON a.entity_type = 'expense' AND a.entity_id = ex.id
|
||||
LEFT JOIN projects p ON p.id = COALESCE(le.project_id, ex.project_id)
|
||||
LEFT JOIN evidence_types et ON le.evidence_type_id = et.id
|
||||
WHERE $where_clause
|
||||
ORDER BY a.created_at DESC
|
||||
";
|
||||
|
||||
$stmt = db()->prepare($query);
|
||||
$stmt->execute($params);
|
||||
$mediaItems = $stmt->fetchAll();
|
||||
|
||||
// Get filter options
|
||||
$authors = db()->prepare("SELECT DISTINCT uploaded_by FROM attachments WHERE tenant_id = ? AND uploaded_by IS NOT NULL ORDER BY uploaded_by");
|
||||
$authors->execute([$tenant_id]);
|
||||
$authorList = $authors->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? AND is_archived = 0 ORDER BY name");
|
||||
$projects->execute([$tenant_id]);
|
||||
$projectList = $projects->fetchAll();
|
||||
|
||||
$evidenceTypes = db()->prepare("SELECT id, name FROM evidence_types WHERE tenant_id = ? ORDER BY name");
|
||||
$evidenceTypes->execute([$tenant_id]);
|
||||
$evidenceTypeList = $evidenceTypes->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Media Gallery";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a href="reports.php" class="text-decoration-none text-muted">Reports</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">Media Gallery</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h2 class="fw-bold mb-0">Media Gallery</h2>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
Total Items: <?= count($mediaItems) ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="GET" class="row g-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold">Project</label>
|
||||
<select name="project_id" class="form-select form-select-sm">
|
||||
<option value="">All Projects</option>
|
||||
<?php foreach ($projectList as $p): ?>
|
||||
<option value="<?= $p['id'] ?>" <?= $filter_project == $p['id'] ? 'selected' : '' ?>><?= htmlspecialchars($p['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Author</label>
|
||||
<select name="author" class="form-select form-select-sm">
|
||||
<option value="">All Authors</option>
|
||||
<?php foreach ($authorList as $author): ?>
|
||||
<option value="<?= htmlspecialchars($author) ?>" <?= $filter_author == $author ? 'selected' : '' ?>><?= htmlspecialchars($author) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">Objective Evidence</label>
|
||||
<select name="evidence_type_id" class="form-select form-select-sm">
|
||||
<option value="">All Evidence</option>
|
||||
<?php foreach ($evidenceTypeList as $et): ?>
|
||||
<option value="<?= $et['id'] ?>" <?= $filter_evidence == $et['id'] ? 'selected' : '' ?>><?= htmlspecialchars($et['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">From</label>
|
||||
<input type="date" name="start_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_start) ?>">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold">To</label>
|
||||
<input type="date" name="end_date" class="form-control form-control-sm" value="<?= htmlspecialchars($filter_end) ?>">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<div class="d-flex gap-1">
|
||||
<button type="submit" class="btn btn-sm btn-primary flex-grow-1">Filter</button>
|
||||
<a href="reports_media.php" class="btn btn-sm btn-outline-secondary" title="Reset"><i class="bi bi-x-lg"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($mediaItems)): ?>
|
||||
<div class="card border-0 shadow-sm text-center py-5">
|
||||
<div class="card-body">
|
||||
<i class="bi bi-images text-muted" style="font-size: 3rem;"></i>
|
||||
<h5 class="mt-3 text-muted">No media files found</h5>
|
||||
<p class="text-muted small">Try adjusting your filters or upload some images/videos.</p>
|
||||
<a href="reports_media.php" class="btn btn-sm btn-outline-secondary">Reset All Filters</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 g-4">
|
||||
<?php foreach ($mediaItems as $item):
|
||||
$isImg = isImage($item['mime_type']);
|
||||
$isVideo = isVideo($item['mime_type']);
|
||||
$thumb = $item['thumbnail_path'] ?: $item['file_path'];
|
||||
|
||||
// If it's a video and no thumbnail, show a placeholder icon
|
||||
$showThumb = true;
|
||||
if ($isVideo && !$item['thumbnail_path']) {
|
||||
$showThumb = false;
|
||||
}
|
||||
?>
|
||||
<div class="col">
|
||||
<div class="card h-100 border-0 shadow-sm overflow-hidden position-relative group">
|
||||
<div class="ratio ratio-1x1 bg-light">
|
||||
<?php if ($showThumb): ?>
|
||||
<img src="<?= htmlspecialchars($thumb) ?>" class="object-fit-cover w-100 h-100 transition" alt="<?= htmlspecialchars($item['file_name']) ?>">
|
||||
<?php else: ?>
|
||||
<div class="d-flex align-items-center justify-content-center bg-dark">
|
||||
<i class="bi bi-play-circle text-white" style="font-size: 2.5rem;"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="overlay d-flex align-items-center justify-content-center bg-dark bg-opacity-50 opacity-0 transition">
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= htmlspecialchars($item['file_path']) ?>" target="_blank" class="btn btn-sm btn-light rounded-circle shadow-sm" title="View Full">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="<?= htmlspecialchars($item['file_path']) ?>" download class="btn btn-sm btn-light rounded-circle shadow-sm" title="Download">
|
||||
<i class="bi bi-download"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<span class="badge <?= $isImg ? 'bg-primary' : 'bg-danger' ?> extra-small">
|
||||
<?= $isImg ? 'IMAGE' : 'VIDEO' ?>
|
||||
</span>
|
||||
<small class="text-muted extra-small"><?= date('M j, Y', strtotime($item['created_at'])) ?></small>
|
||||
</div>
|
||||
<div class="text-truncate small fw-bold text-dark" title="<?= htmlspecialchars($item['file_name']) ?>">
|
||||
<?= htmlspecialchars($item['file_name']) ?>
|
||||
</div>
|
||||
<div class="extra-small text-muted text-truncate mt-1">
|
||||
<i class="bi bi-folder2 me-1"></i> <?= htmlspecialchars($item['project_name'] ?: 'No Project') ?>
|
||||
</div>
|
||||
<?php if ($item['evidence_type_name']): ?>
|
||||
<div class="extra-small text-muted text-truncate">
|
||||
<i class="bi bi-shield-check me-1"></i> <?= htmlspecialchars($item['evidence_type_name']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="d-flex align-items-center mt-2 pt-2 border-top">
|
||||
<div class="bg-light rounded-circle p-1 me-2" style="width: 20px; height: 20px; display: flex; align-items: center; justify-content: center;">
|
||||
<i class="bi bi-person extra-small"></i>
|
||||
</div>
|
||||
<span class="extra-small text-muted"><?= htmlspecialchars($item['uploaded_by'] ?: 'System') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.group:hover .overlay { opacity: 1 !important; }
|
||||
.transition { transition: all 0.3s ease; }
|
||||
.extra-small { font-size: 0.7rem; }
|
||||
.object-fit-cover { object-fit: cover; }
|
||||
.bg-opacity-50 { background-color: rgba(0,0,0,0.5) !important; }
|
||||
.opacity-0 { opacity: 0; }
|
||||
</style>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
105
reset_password.php
Normal file
105
reset_password.php
Normal file
@ -0,0 +1,105 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
|
||||
$token = $_GET['token'] ?? '';
|
||||
$error = '';
|
||||
$success = false;
|
||||
|
||||
if (!$token) {
|
||||
header('Location: login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
$stmt = db()->prepare("SELECT * FROM password_resets WHERE token = ? AND expires_at > NOW() LIMIT 1");
|
||||
$stmt->execute([$token]);
|
||||
$reset = $stmt->fetch();
|
||||
|
||||
if (!$reset) {
|
||||
$error = "This password reset link is invalid or has expired.";
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $reset) {
|
||||
$password = $_POST['password'] ?? '';
|
||||
$confirm = $_POST['confirm_password'] ?? '';
|
||||
|
||||
if (strlen($password) < 8) {
|
||||
$error = "Password must be at least 8 characters long.";
|
||||
} elseif ($password !== $confirm) {
|
||||
$error = "Passwords do not match.";
|
||||
} else {
|
||||
$hashed = password_hash($password, PASSWORD_DEFAULT);
|
||||
|
||||
db()->beginTransaction();
|
||||
try {
|
||||
$stmt = db()->prepare("UPDATE users SET password = ?, require_password_change = 0 WHERE email = ?");
|
||||
$stmt->execute([$hashed, $reset['email']]);
|
||||
|
||||
$stmt = db()->prepare("DELETE FROM password_resets WHERE email = ?");
|
||||
$stmt->execute([$reset['email']]);
|
||||
|
||||
// Log the password change activity
|
||||
$ip = Auth::getIpAddress();
|
||||
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
|
||||
$stmt->execute([0, 'Password Changed', "Email: {$reset['email']}, IP: $ip"]);
|
||||
|
||||
db()->commit();
|
||||
$success = true;
|
||||
} catch (\Exception $e) {
|
||||
db()->rollBack();
|
||||
$error = "An error occurred while resetting your password.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Reset Password - SR&ED Manager</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #f8fafc; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.login-card { width: 100%; max-width: 400px; border: none; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); }
|
||||
.btn-primary { background-color: #3b82f6; border: none; padding: 10px; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card login-card p-4">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="fw-bold text-primary">NEW PASSWORD</h3>
|
||||
<p class="text-muted small">Please set your new secure password</p>
|
||||
</div>
|
||||
|
||||
<?php if ($success): ?>
|
||||
<div class="alert alert-success small">
|
||||
Your password has been successfully reset.
|
||||
</div>
|
||||
<a href="login.php" class="btn btn-primary w-100">Login Now</a>
|
||||
<?php elseif ($error && !$reset): ?>
|
||||
<div class="alert alert-danger small"><?= htmlspecialchars($error) ?></div>
|
||||
<a href="forgot_password.php" class="btn btn-outline-secondary w-100">Request New Link</a>
|
||||
<?php else: ?>
|
||||
<?php if ($error): ?>
|
||||
<div class="alert alert-danger small"><?= htmlspecialchars($error) ?></div>
|
||||
<?php endif; ?>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">New Password</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="••••••••" required autofocus>
|
||||
<div class="form-text extra-small">Minimum 8 characters.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Confirm New Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" placeholder="••••••••" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 mb-3">Reset Password</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
4
samples/expenses_template.csv
Normal file
4
samples/expenses_template.csv
Normal file
@ -0,0 +1,4 @@
|
||||
project_code,supplier_name,expense_type,amount,date,notes
|
||||
P001,ACME Corp,Materials,1250.00,2026-02-10,Lumber for site A
|
||||
P002,Build-It Co,Subcontractors,5000.00,2026-02-12,Foundation work
|
||||
P001,Global Telecom,Overhead,150.00,2026-02-15,Monthly internet
|
||||
|
4
samples/labour_template.csv
Normal file
4
samples/labour_template.csv
Normal file
@ -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
|
||||
|
4
samples/suppliers_template.csv
Normal file
4
samples/suppliers_template.csv
Normal file
@ -0,0 +1,4 @@
|
||||
name,type,contact_info
|
||||
ACME Corp,supplier,admin@acme.com
|
||||
Build-It Co,contractor,contact@buildit.com
|
||||
Global Telecom,supplier,info@globaltelecom.com
|
||||
|
349
settings.php
Normal file
349
settings.php
Normal file
@ -0,0 +1,349 @@
|
||||
<?php
|
||||
/**
|
||||
* Settings Page - Manage System Datasets
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Form Submissions
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$action = $_POST['action'] ?? '';
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$name = $_POST['name'] ?? '';
|
||||
$error = '';
|
||||
|
||||
if ($action === 'add_labour_type' && $name) {
|
||||
$stmt = db()->prepare("INSERT INTO labour_types (tenant_id, name) VALUES (?, ?)");
|
||||
$stmt->execute([$tenant_id, $name]);
|
||||
} elseif ($action === 'edit_labour_type' && $id && $name) {
|
||||
$stmt = db()->prepare("UPDATE labour_types SET name = ? WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$name, $id, $tenant_id]);
|
||||
} elseif ($action === 'delete_labour_type' && $id) {
|
||||
$check = db()->prepare("SELECT COUNT(*) FROM labour_entries WHERE labour_type_id = ? AND tenant_id = ?");
|
||||
$check->execute([$id, $tenant_id]);
|
||||
if ($check->fetchColumn() > 0) {
|
||||
$error = "Cannot delete: Labour type is used in labour entries.";
|
||||
} else {
|
||||
$stmt = db()->prepare("DELETE FROM labour_types WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
}
|
||||
}
|
||||
|
||||
elseif ($action === 'add_evidence_type' && $name) {
|
||||
$stmt = db()->prepare("INSERT INTO evidence_types (tenant_id, name) VALUES (?, ?)");
|
||||
$stmt->execute([$tenant_id, $name]);
|
||||
} elseif ($action === 'edit_evidence_type' && $id && $name) {
|
||||
$stmt = db()->prepare("UPDATE evidence_types SET name = ? WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$name, $id, $tenant_id]);
|
||||
} elseif ($action === 'delete_evidence_type' && $id) {
|
||||
$check = db()->prepare("SELECT COUNT(*) FROM labour_entries WHERE evidence_type_id = ? AND tenant_id = ?");
|
||||
$check->execute([$id, $tenant_id]);
|
||||
if ($check->fetchColumn() > 0) {
|
||||
$error = "Cannot delete: Evidence type is used in labour entries.";
|
||||
} else {
|
||||
$stmt = db()->prepare("DELETE FROM evidence_types WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
}
|
||||
}
|
||||
|
||||
elseif ($action === 'add_expense_type' && $name) {
|
||||
$stmt = db()->prepare("INSERT INTO expense_types (tenant_id, name) VALUES (?, ?)");
|
||||
$stmt->execute([$tenant_id, $name]);
|
||||
} elseif ($action === 'edit_expense_type' && $id && $name) {
|
||||
$stmt = db()->prepare("UPDATE expense_types SET name = ? WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$name, $id, $tenant_id]);
|
||||
} elseif ($action === 'delete_expense_type' && $id) {
|
||||
$check = db()->prepare("SELECT COUNT(*) FROM expenses WHERE expense_type_id = ? AND tenant_id = ?");
|
||||
$check->execute([$id, $tenant_id]);
|
||||
if ($check->fetchColumn() > 0) {
|
||||
$error = "Cannot delete: Expense type is used in expense logs.";
|
||||
} else {
|
||||
$stmt = db()->prepare("DELETE FROM expense_types WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
}
|
||||
}
|
||||
|
||||
elseif ($action === 'add_team' && $name) {
|
||||
$stmt = db()->prepare("INSERT INTO teams (tenant_id, name) VALUES (?, ?)");
|
||||
$stmt->execute([$tenant_id, $name]);
|
||||
} elseif ($action === 'edit_team' && $id && $name) {
|
||||
$stmt = db()->prepare("UPDATE teams SET name = ? WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$name, $id, $tenant_id]);
|
||||
} elseif ($action === 'delete_team' && $id) {
|
||||
$check = db()->prepare("SELECT COUNT(*) FROM employee_teams WHERE team_id = ? AND tenant_id = ?");
|
||||
$check->execute([$id, $tenant_id]);
|
||||
if ($check->fetchColumn() > 0) {
|
||||
$error = "Cannot delete: Team has assigned employees.";
|
||||
} else {
|
||||
$stmt = db()->prepare("DELETE FROM teams WHERE id = ? AND tenant_id = ?");
|
||||
$stmt->execute([$id, $tenant_id]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($error) {
|
||||
header("Location: settings.php?error=" . urlencode($error));
|
||||
} else {
|
||||
header("Location: settings.php?success=1");
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
// Fetch all datasets
|
||||
$labourTypes = db()->prepare("SELECT * FROM labour_types WHERE tenant_id = ? ORDER BY name");
|
||||
$labourTypes->execute([$tenant_id]);
|
||||
$labourTypeList = $labourTypes->fetchAll();
|
||||
|
||||
$evidenceTypes = db()->prepare("SELECT * FROM evidence_types WHERE tenant_id = ? ORDER BY name");
|
||||
$evidenceTypes->execute([$tenant_id]);
|
||||
$evidenceTypeList = $evidenceTypes->fetchAll();
|
||||
|
||||
$expenseTypes = db()->prepare("SELECT * FROM expense_types WHERE tenant_id = ? ORDER BY name");
|
||||
$expenseTypes->execute([$tenant_id]);
|
||||
$expenseTypeList = $expenseTypes->fetchAll();
|
||||
|
||||
$teams = db()->prepare("SELECT * FROM teams WHERE tenant_id = ? ORDER BY name");
|
||||
$teams->execute([$tenant_id]);
|
||||
$teamList = $teams->fetchAll();
|
||||
|
||||
$pageTitle = "SR&ED Manager - Settings";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0">System Settings & Datasets</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<span class="badge bg-success py-2 px-3">Dataset updated successfully</span>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_GET['error'])): ?>
|
||||
<span class="badge bg-danger py-2 px-3"><?= htmlspecialchars($_GET['error']) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Labour Types -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Labour Types</span>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addLabourTypeModal">+ Add</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead><tr><th>Name</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($labourTypeList as $item): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($item['name']) ?></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-link text-primary p-0 me-2" onclick="editItem('edit_labour_type', <?= $item['id'] ?>, '<?= addslashes($item['name']) ?>')">Edit</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteItem('delete_labour_type', <?= $item['id'] ?>)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Evidence Types -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Evidence Types</span>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addEvidenceTypeModal">+ Add</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead><tr><th>Name</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($evidenceTypeList as $item): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($item['name']) ?></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-link text-primary p-0 me-2" onclick="editItem('edit_evidence_type', <?= $item['id'] ?>, '<?= addslashes($item['name']) ?>')">Edit</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteItem('delete_evidence_type', <?= $item['id'] ?>)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense Types -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Expense Types</span>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addExpenseTypeModal">+ Add</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead><tr><th>Name</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($expenseTypeList as $item): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($item['name']) ?></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-link text-primary p-0 me-2" onclick="editItem('edit_expense_type', <?= $item['id'] ?>, '<?= addslashes($item['name']) ?>')">Edit</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteItem('delete_expense_type', <?= $item['id'] ?>)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teams -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-bold">Teams</span>
|
||||
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#addTeamModal">+ Add</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead><tr><th>Name</th><th class="text-end">Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($teamList as $item): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($item['name']) ?></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-link text-primary p-0 me-2" onclick="editItem('edit_team', <?= $item['id'] ?>, '<?= addslashes($item['name']) ?>')">Edit</button>
|
||||
<button class="btn btn-sm btn-link text-danger p-0" onclick="deleteItem('delete_team', <?= $item['id'] ?>)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Management -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-header bg-white">
|
||||
<span class="fw-bold">Data Management & Imports</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-muted mb-4">Import legacy data or bulk records from other systems using CSV templates.</p>
|
||||
<div class="d-grid gap-3">
|
||||
<a href="import_suppliers.php" class="btn btn-outline-secondary d-flex justify-content-between align-items-center py-2">
|
||||
<span><i class="bi bi-truck me-2"></i>Import Suppliers</span>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="import_expenses.php" class="btn btn-outline-secondary d-flex justify-content-between align-items-center py-2">
|
||||
<span><i class="bi bi-receipt me-2"></i>Import Expenses</span>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
<a href="import_labour.php" class="btn btn-outline-secondary d-flex justify-content-between align-items-center py-2">
|
||||
<span><i class="bi bi-clock-history me-2"></i>Import Labour Activities</span>
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Configuration -->
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 border-0 shadow-sm text-white" style="background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);">
|
||||
<div class="card-body d-flex flex-column justify-content-center align-items-center text-center py-5">
|
||||
<i class="bi bi-building fs-1 mb-3"></i>
|
||||
<h5 class="fw-bold">Company Preferences</h5>
|
||||
<p class="small mb-4 opacity-75">Configure your company identity, logo, fiscal year end, and notification settings.</p>
|
||||
<a href="company_settings.php" class="btn btn-light px-4 rounded-pill fw-bold">Manage Company Info</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="editItemModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header"><h5 class="modal-title fw-bold" id="editItemTitle">Edit Item</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<form method="POST">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="action" id="editItemAction">
|
||||
<input type="hidden" name="id" id="editItemId">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Name</label>
|
||||
<input type="text" name="name" id="editItemName" class="form-control" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0"><button type="submit" class="btn btn-primary px-4">Save Changes</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="deleteForm" method="POST" style="display:none;">
|
||||
<input type="hidden" name="action" id="deleteAction">
|
||||
<input type="hidden" name="id" id="deleteId">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function editItem(action, id, name) {
|
||||
document.getElementById('editItemAction').value = action;
|
||||
document.getElementById('editItemId').value = id;
|
||||
document.getElementById('editItemName').value = name;
|
||||
document.getElementById('editItemTitle').innerText = 'Edit ' + action.replace('edit_', '').replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
new bootstrap.Modal(document.getElementById('editItemModal')).show();
|
||||
}
|
||||
|
||||
function deleteItem(action, id) {
|
||||
if (confirm('Are you sure you want to delete this item? This action cannot be undone.')) {
|
||||
document.getElementById('deleteAction').value = action;
|
||||
document.getElementById('deleteId').value = id;
|
||||
document.getElementById('deleteForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modals -->
|
||||
<?php
|
||||
$modals = [
|
||||
['id' => 'addLabourTypeModal', 'title' => 'Add Labour Type', 'action' => 'add_labour_type'],
|
||||
['id' => 'addEvidenceTypeModal', 'title' => 'Add Evidence Type', 'action' => 'add_evidence_type'],
|
||||
['id' => 'addExpenseTypeModal', 'title' => 'Add Expense Type', 'action' => 'add_expense_type'],
|
||||
['id' => 'addTeamModal', 'title' => 'Add Team', 'action' => 'add_team'],
|
||||
];
|
||||
foreach ($modals as $m):
|
||||
?>
|
||||
<div class="modal fade" id="<?= $m['id'] ?>" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header"><h5 class="modal-title fw-bold"><?= $m['title'] ?></h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<form method="POST">
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="action" value="<?= $m['action'] ?>">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">Name</label>
|
||||
<input type="text" name="name" class="form-control" placeholder="Enter name..." required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0"><button type="submit" class="btn btn-primary px-4">Add Item</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
510
sred_claim_report.php
Normal file
510
sred_claim_report.php
Normal file
@ -0,0 +1,510 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
$selected_year = (int)($_GET['year'] ?? date('Y'));
|
||||
|
||||
// 1. Get Company Settings
|
||||
$stmt = db()->prepare("SELECT * FROM company_settings WHERE id = 1");
|
||||
$stmt->execute();
|
||||
$company = $stmt->fetch();
|
||||
|
||||
// 2. Determine Fiscal Year Range
|
||||
$fiscal_end_raw = $company['fiscal_year_end'] ?? '12-31';
|
||||
$fiscal_month = (int)date('m', strtotime($fiscal_end_raw));
|
||||
$fiscal_day = (int)date('d', strtotime($fiscal_end_raw));
|
||||
|
||||
// If fiscal end is 2024-03-31, and selected year is 2024:
|
||||
// Start: 2023-04-01, End: 2024-03-31
|
||||
$end_date = sprintf('%d-%02d-%02d', $selected_year, $fiscal_month, $fiscal_day);
|
||||
$start_date = date('Y-m-d', strtotime($end_date . ' -1 year +1 day'));
|
||||
|
||||
// 3. Fetch Projects active in this period (with labour or expenses)
|
||||
$projects_query = "
|
||||
SELECT DISTINCT p.*
|
||||
FROM projects p
|
||||
LEFT JOIN labour_entries le ON p.id = le.project_id AND le.entry_date BETWEEN ? AND ?
|
||||
LEFT JOIN expenses e ON p.id = e.project_id AND e.entry_date BETWEEN ? AND ?
|
||||
WHERE (le.id IS NOT NULL OR e.id IS NOT NULL)
|
||||
ORDER BY p.name ASC
|
||||
";
|
||||
$stmt = db()->prepare($projects_query);
|
||||
$stmt->execute([$start_date, $end_date, $start_date, $end_date]);
|
||||
$active_projects = $stmt->fetchAll();
|
||||
|
||||
// 4. Helper for Labour Data
|
||||
function getLabourForProject($projectId, $start, $end) {
|
||||
$stmt = db()->prepare("
|
||||
SELECT le.*, e.name as employee_name, lt.name as labour_type
|
||||
FROM labour_entries le
|
||||
JOIN employees e ON le.employee_id = e.id
|
||||
LEFT JOIN labour_types lt ON le.labour_type_id = lt.id
|
||||
WHERE le.project_id = ? AND le.entry_date BETWEEN ? AND ?
|
||||
ORDER BY le.entry_date ASC
|
||||
");
|
||||
$stmt->execute([$projectId, $start, $end]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// 5. Helper for Expense Data
|
||||
function getExpensesForProject($projectId, $start, $end) {
|
||||
$stmt = db()->prepare("
|
||||
SELECT e.*, s.name as supplier_name, et.name as expense_type
|
||||
FROM expenses e
|
||||
JOIN suppliers s ON e.supplier_id = s.id
|
||||
LEFT JOIN expense_types et ON e.expense_type_id = et.id
|
||||
WHERE e.project_id = ? AND e.entry_date BETWEEN ? AND ?
|
||||
ORDER BY e.entry_date ASC
|
||||
");
|
||||
$stmt->execute([$projectId, $start, $end]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
// 6. Helper for Summary Calendar (Labour Hours)
|
||||
$summary_labour_query = "
|
||||
SELECT p.id as project_id, p.name as project_name, DATE_FORMAT(le.entry_date, '%Y-%m') as month, SUM(le.hours) as total_hours, SUM(le.hours * IFNULL(le.hourly_rate, 0)) as total_cost
|
||||
FROM labour_entries le
|
||||
JOIN projects p ON le.project_id = p.id
|
||||
WHERE le.entry_date BETWEEN ? AND ?
|
||||
GROUP BY p.id, p.name, month
|
||||
";
|
||||
$stmt = db()->prepare($summary_labour_query);
|
||||
$stmt->execute([$start_date, $end_date]);
|
||||
$summary_labour_data = $stmt->fetchAll();
|
||||
|
||||
// 7. Helper for Summary Calendar (Expenses)
|
||||
$summary_expense_query = "
|
||||
SELECT p.id as project_id, p.name as project_name, DATE_FORMAT(e.entry_date, '%Y-%m') as month, SUM(e.amount) as total_amount
|
||||
FROM expenses e
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
WHERE e.entry_date BETWEEN ? AND ?
|
||||
GROUP BY p.id, p.name, month
|
||||
";
|
||||
$stmt = db()->prepare($summary_expense_query);
|
||||
$stmt->execute([$start_date, $end_date]);
|
||||
$summary_expense_data = $stmt->fetchAll();
|
||||
|
||||
// Organize Summary Data
|
||||
$months = [];
|
||||
try {
|
||||
$current_dt = new DateTime($start_date);
|
||||
$current_dt->modify('first day of this month');
|
||||
$end_dt = new DateTime($end_date);
|
||||
$end_dt->modify('first day of this month');
|
||||
|
||||
while ($current_dt <= $end_dt) {
|
||||
$months[] = $current_dt->format('Y-m');
|
||||
$current_dt->modify('+1 month');
|
||||
if (count($months) > 13) break; // Safety for roughly 1 year
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// Fallback if DateTime fails
|
||||
$months = [date('Y-m', strtotime($start_date))];
|
||||
}
|
||||
|
||||
$labour_matrix = [];
|
||||
$project_names = [];
|
||||
foreach ($summary_labour_data as $row) {
|
||||
$pid = $row['project_id'];
|
||||
$project_names[$pid] = $row['project_name'];
|
||||
$labour_matrix[$pid][$row['month']] = ['hours' => $row['total_hours'], 'cost' => $row['total_cost']];
|
||||
}
|
||||
|
||||
$expense_matrix = [];
|
||||
foreach ($summary_expense_data as $row) {
|
||||
$pid = $row['project_id'];
|
||||
$project_names[$pid] = $row['project_name'];
|
||||
$expense_matrix[$pid][$row['month']] = $row['total_amount'];
|
||||
}
|
||||
|
||||
$pageTitle = "SRED Claim Report - " . $selected_year;
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= $pageTitle ?></title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-before: always; }
|
||||
body { background: white; }
|
||||
.container { width: 100%; max-width: 100%; margin: 0; padding: 0; }
|
||||
.card { border: none !important; box-shadow: none !important; }
|
||||
}
|
||||
body { background: #f8f9fa; font-family: 'Inter', sans-serif; }
|
||||
.report-page { background: white; min-height: 297mm; padding: 20mm; margin: 20px auto; box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15); width: 210mm; }
|
||||
.title-page { display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; }
|
||||
.toc-item { display: flex; justify-content: space-between; border-bottom: 1px dotted #ccc; margin-bottom: 10px; }
|
||||
.chart-container { height: 300px; }
|
||||
.table-tight th, .table-tight td { padding: 4px 8px; font-size: 0.85rem; }
|
||||
.calendar-table th, .calendar-table td { font-size: 0.75rem; text-align: center; vertical-align: middle; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="no-print bg-dark text-white p-3 sticky-top shadow d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="sred_claim_report_selector.php" class="btn btn-sm btn-outline-light me-2"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
<strong>SRED Claim Report Generator</strong>
|
||||
</div>
|
||||
<button onclick="window.print()" class="btn btn-primary btn-sm"><i class="bi bi-printer"></i> Print Report / Save PDF</button>
|
||||
</div>
|
||||
|
||||
<!-- PAGE 1: TITLE PAGE -->
|
||||
<div class="report-page title-page">
|
||||
<?php if (!empty($company['logo_path'])): ?>
|
||||
<img src="<?= htmlspecialchars($company['logo_path']) ?>" alt="Logo" class="mb-5" style="max-height: 150px;">
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-top: auto; margin-bottom: auto;">
|
||||
<h1 class="display-3 fw-bold mb-4"><?= htmlspecialchars($company['company_name'] ?? 'Company Name') ?></h1>
|
||||
<h2 class="text-primary mb-5">SRED CLAIM REPORT</h2>
|
||||
<h4 class="text-muted">Fiscal Year: <?= $selected_year ?></h4>
|
||||
<p class="mt-3 text-muted">Range: <?= date('M d, Y', strtotime($start_date)) ?> to <?= date('M d, Y', strtotime($end_date)) ?></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto border-top pt-4 w-100">
|
||||
<p class="mb-1 fw-bold"><?= htmlspecialchars($company['address_1'] ?? '') ?></p>
|
||||
<?php if ($company['address_2']): ?><p class="mb-1"><?= htmlspecialchars($company['address_2']) ?></p><?php endif; ?>
|
||||
<p class="mb-1"><?= htmlspecialchars($company['city'] ?? '') ?>, <?= htmlspecialchars($company['province'] ?? '') ?> <?= htmlspecialchars($company['postal_code'] ?? '') ?></p>
|
||||
<p class="mb-1">Phone: <?= htmlspecialchars($company['phone'] ?? '') ?> | Email: <?= htmlspecialchars($company['email'] ?? '') ?></p>
|
||||
<p class="mb-0">Website: <?= htmlspecialchars($company['website'] ?? '') ?></p>
|
||||
<?php if ($company['business_number']): ?>
|
||||
<p class="mt-2 fw-bold">Business Number: <?= htmlspecialchars($company['business_number']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PAGE 2: TABLE OF CONTENTS -->
|
||||
<div class="report-page page-break">
|
||||
<h2 class="mb-5 border-bottom pb-3">Table of Contents</h2>
|
||||
<div class="mt-4">
|
||||
<div class="toc-item"><span>1. Title Page</span><span>1</span></div>
|
||||
<div class="toc-item"><span>2. Table of Contents</span><span>2</span></div>
|
||||
<?php
|
||||
$page_counter = 3;
|
||||
foreach ($active_projects as $p):
|
||||
$proj_start_page = $page_counter;
|
||||
?>
|
||||
<div class="toc-item fw-bold mt-3"><span>Project: <?= htmlspecialchars($p['name']) ?></span><span><?= $page_counter++ ?></span></div>
|
||||
<div class="toc-item ps-4 text-muted"><span>Insights & Charts</span><span><?= ($page_counter - 1) ?></span></div>
|
||||
<div class="toc-item ps-4 text-muted"><span>Labour Detail Report</span><span><?= $page_counter++ ?></span></div>
|
||||
<div class="toc-item ps-4 text-muted"><span>Expense Detail Report</span><span><?= $page_counter++ ?></span></div>
|
||||
<?php endforeach; ?>
|
||||
<div class="toc-item fw-bold mt-4"><span>Summarized Claim (Calendar Views)</span><span><?= $page_counter++ ?></span></div>
|
||||
<div class="toc-item ps-4 text-muted"><span>Labour Summary</span><span><?= ($page_counter - 1) ?></span></div>
|
||||
<div class="toc-item ps-4 text-muted"><span>Expense Summary</span><span><?= $page_counter++ ?></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROJECT PAGES -->
|
||||
<?php foreach ($active_projects as $idx => $project):
|
||||
$labour = getLabourForProject($project['id'], $start_date, $end_date);
|
||||
$expenses = getExpensesForProject($project['id'], $start_date, $end_date);
|
||||
|
||||
// Process Insights
|
||||
$employee_hours = [];
|
||||
$labour_type_hours = [];
|
||||
$total_proj_hours = 0;
|
||||
$total_proj_labour_cost = 0;
|
||||
foreach ($labour as $l) {
|
||||
$employee_hours[$l['employee_name']] = ($employee_hours[$l['employee_name']] ?? 0) + (float)$l['hours'];
|
||||
$labour_type_hours[$l['labour_type'] ?? 'Other'] = ($labour_type_hours[$l['labour_type'] ?? 'Other'] ?? 0) + (float)$l['hours'];
|
||||
$total_proj_hours += (float)$l['hours'];
|
||||
$total_proj_labour_cost += (float)$l['hours'] * (float)($l['hourly_rate'] ?? 0);
|
||||
}
|
||||
arsort($employee_hours);
|
||||
$top_employees = array_slice($employee_hours, 0, 5, true);
|
||||
|
||||
$total_proj_expenses = 0;
|
||||
foreach ($expenses as $e) {
|
||||
$total_proj_expenses += (float)$e['amount'];
|
||||
}
|
||||
?>
|
||||
<!-- Project Page 1: Insights -->
|
||||
<div class="report-page page-break">
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 border-bottom pb-3">
|
||||
<div>
|
||||
<h6 class="text-uppercase text-primary fw-bold mb-1">Project Analysis</h6>
|
||||
<h2 class="mb-0"><?= htmlspecialchars($project['name']) ?></h2>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-light text-dark border">Project ID: <?= htmlspecialchars($project['code'] ?? $project['id']) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="lead text-muted mb-4"><?= nl2br(htmlspecialchars($project['description'] ?? 'No project description available.')) ?></p>
|
||||
|
||||
<div class="row mb-5">
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted small text-uppercase">Total Labour Hours</h6>
|
||||
<h3 class="mb-0 fw-bold"><?= number_format($total_proj_hours, 1) ?> h</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="card bg-light border-0">
|
||||
<div class="card-body">
|
||||
<h6 class="text-muted small text-uppercase">Total Estimated Cost</h6>
|
||||
<h3 class="mb-0 fw-bold text-success">$<?= number_format($total_proj_labour_cost + $total_proj_expenses, 2) ?></h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6">
|
||||
<h6 class="fw-bold mb-3">Top Employees by Hours</h6>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart_emp_<?= $project['id'] ?>"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<h6 class="fw-bold mb-3">Labour by Category</h6>
|
||||
<div class="chart-container">
|
||||
<canvas id="chart_type_<?= $project['id'] ?>"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
new Chart(document.getElementById('chart_emp_<?= $project['id'] ?>').getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: <?= json_encode(array_keys($top_employees)) ?>,
|
||||
datasets: [{
|
||||
label: 'Hours',
|
||||
data: <?= json_encode(array_values($top_employees)) ?>,
|
||||
backgroundColor: '#0d6efd'
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
|
||||
});
|
||||
new Chart(document.getElementById('chart_type_<?= $project['id'] ?>').getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: <?= json_encode(array_keys($labour_type_hours)) ?>,
|
||||
datasets: [{
|
||||
data: <?= json_encode(array_values($labour_type_hours)) ?>,
|
||||
backgroundColor: ['#0d6efd', '#198754', '#ffc107', '#0dcaf0', '#6c757d']
|
||||
}]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false }
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<!-- Project Page 2: Labour Details -->
|
||||
<div class="report-page page-break">
|
||||
<h4 class="mb-4 border-bottom pb-2">Labour Detail Report: <?= htmlspecialchars($project['name']) ?></h4>
|
||||
<table class="table table-bordered table-tight">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Employee</th>
|
||||
<th>Activity Type</th>
|
||||
<th class="text-end">Hours</th>
|
||||
<th class="text-end">Rate</th>
|
||||
<th class="text-end">Total Wage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($labour)): ?>
|
||||
<tr><td colspan="6" class="text-center text-muted">No labour entries recorded.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($labour as $l):
|
||||
$cost = (float)$l['hours'] * (float)($l['hourly_rate'] ?? 0);
|
||||
?>
|
||||
<tr>
|
||||
<td><?= $l['entry_date'] ?></td>
|
||||
<td><?= htmlspecialchars($l['employee_name']) ?></td>
|
||||
<td><?= htmlspecialchars($l['labour_type'] ?? 'N/A') ?></td>
|
||||
<td class="text-end"><?= number_format((float)$l['hours'], 2) ?></td>
|
||||
<td class="text-end"><?= $l['hourly_rate'] ? '$'.number_format((float)$l['hourly_rate'], 2) : '-' ?></td>
|
||||
<td class="text-end fw-bold"><?= $cost > 0 ? '$'.number_format($cost, 2) : '-' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="bg-light">
|
||||
<tr>
|
||||
<td colspan="3" class="text-end fw-bold">Project Totals:</td>
|
||||
<td class="text-end fw-bold text-primary"><?= number_format($total_proj_hours, 2) ?> h</td>
|
||||
<td></td>
|
||||
<td class="text-end fw-bold text-success">$<?= number_format($total_proj_labour_cost, 2) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
<div class="mt-4 extra-small text-muted">
|
||||
Note: Hourly rates are recorded at the time of entry to reflect historical wage adjustments accurately.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project Page 3: Expense Details -->
|
||||
<div class="report-page page-break">
|
||||
<h4 class="mb-4 border-bottom pb-2">Expense Detail Report: <?= htmlspecialchars($project['name']) ?></h4>
|
||||
<table class="table table-bordered table-tight">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Supplier</th>
|
||||
<th>Expense Type</th>
|
||||
<th>Notes</th>
|
||||
<th class="text-end">Allocation</th>
|
||||
<th class="text-end">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($expenses)): ?>
|
||||
<tr><td colspan="6" class="text-center text-muted">No expenses recorded.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($expenses as $e): ?>
|
||||
<tr>
|
||||
<td><?= $e['entry_date'] ?></td>
|
||||
<td><?= htmlspecialchars($e['supplier_name']) ?></td>
|
||||
<td><?= htmlspecialchars($e['expense_type'] ?? 'N/A') ?></td>
|
||||
<td style="max-width: 200px;" class="text-truncate"><?= htmlspecialchars($e['notes'] ?? '') ?></td>
|
||||
<td class="text-end"><?= number_format((float)$e['allocation_percent'], 0) ?>%</td>
|
||||
<td class="text-end fw-bold">$<?= number_format((float)$e['amount'], 2) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="bg-light">
|
||||
<tr>
|
||||
<td colspan="5" class="text-end fw-bold">Total Expenses:</td>
|
||||
<td class="text-end fw-bold text-success">$<?= number_format($total_proj_expenses, 2) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- SUMMARIZED CLAIM - LABOUR -->
|
||||
<div class="report-page page-break">
|
||||
<h3 class="mb-4 border-bottom pb-2">Summarized Claim: Monthly Labour Hours</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered calendar-table">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="text-start">Project Name</th>
|
||||
<?php foreach ($months as $m): ?>
|
||||
<th><?= date('M y', strtotime($m)) ?></th>
|
||||
<?php endforeach; ?>
|
||||
<th class="bg-secondary text-white">Total $</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$grand_total_hours = 0;
|
||||
$grand_total_cost = 0;
|
||||
foreach ($labour_matrix as $pid => $data):
|
||||
$row_cost = 0;
|
||||
$pname = $project_names[$pid] ?? 'Unknown Project';
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-start fw-bold"><?= htmlspecialchars($pname) ?></td>
|
||||
<?php foreach ($months as $m):
|
||||
$h = (float)($data[$m]['hours'] ?? 0);
|
||||
$c = (float)($data[$m]['cost'] ?? 0);
|
||||
$row_cost += $c;
|
||||
$grand_total_hours += $h;
|
||||
?>
|
||||
<td><?= $h > 0 ? number_format($h, 1) : '-' ?></td>
|
||||
<?php endforeach; ?>
|
||||
<td class="fw-bold">$<?= number_format($row_cost, 0) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
$grand_total_cost += $row_cost;
|
||||
endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="bg-light fw-bold">
|
||||
<tr>
|
||||
<td class="text-start">Monthly Totals</td>
|
||||
<?php foreach ($months as $m):
|
||||
$m_hours = 0;
|
||||
foreach($labour_matrix as $pid => $d) $m_hours += (float)($d[$m]['hours'] ?? 0);
|
||||
?>
|
||||
<td><?= $m_hours > 0 ? number_format($m_hours, 1) : '-' ?></td>
|
||||
<?php endforeach; ?>
|
||||
<td class="text-success">$<?= number_format($grand_total_cost, 2) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<p class="small text-muted mt-3">All amounts are calculated using the recorded hourly rate at the time of labour entry.</p>
|
||||
</div>
|
||||
|
||||
<!-- SUMMARIZED CLAIM - EXPENSES -->
|
||||
<div class="report-page page-break">
|
||||
<h3 class="mb-4 border-bottom pb-2">Summarized Claim: Monthly Expenses</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered calendar-table">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="text-start">Project Name</th>
|
||||
<?php foreach ($months as $m): ?>
|
||||
<th><?= date('M y', strtotime($m)) ?></th>
|
||||
<?php endforeach; ?>
|
||||
<th class="bg-secondary text-white">Total $</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php
|
||||
$grand_total_expense = 0;
|
||||
foreach ($expense_matrix as $pid => $data):
|
||||
$row_amount = 0;
|
||||
$pname = $project_names[$pid] ?? 'Unknown Project';
|
||||
?>
|
||||
<tr>
|
||||
<td class="text-start fw-bold"><?= htmlspecialchars($pname) ?></td>
|
||||
<?php foreach ($months as $m):
|
||||
$a = (float)($data[$m] ?? 0);
|
||||
$row_amount += $a;
|
||||
?>
|
||||
<td><?= $a > 0 ? '$'.number_format($a, 0) : '-' ?></td>
|
||||
<?php endforeach; ?>
|
||||
<td class="fw-bold">$<?= number_format($row_amount, 0) ?></td>
|
||||
</tr>
|
||||
<?php
|
||||
$grand_total_expense += $row_amount;
|
||||
endforeach; ?>
|
||||
</tbody>
|
||||
<tfoot class="bg-light fw-bold">
|
||||
<tr>
|
||||
<td class="text-start">Monthly Totals</td>
|
||||
<?php foreach ($months as $m):
|
||||
$m_amt = 0;
|
||||
foreach($expense_matrix as $pid => $d) $m_amt += (float)($d[$m] ?? 0);
|
||||
?>
|
||||
<td><?= $m_amt > 0 ? '$'.number_format($m_amt, 0) : '-' ?></td>
|
||||
<?php endforeach; ?>
|
||||
<td class="text-success">$<?= number_format($grand_total_expense, 2) ?></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<div class="border p-4 d-inline-block">
|
||||
<h5 class="text-uppercase text-muted small mb-3">Total Captured Wages and Expenses for SR&ED Claim</h5>
|
||||
<h1 class="display-4 fw-bold text-primary">$<?= number_format($grand_total_cost + $grand_total_expense, 2) ?></h1>
|
||||
<p class="mb-0 text-muted">Includes all Labour Wages and Project Expenses for Fiscal Year <?= $selected_year ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="no-print mt-5 text-center p-4 text-muted border-top bg-white">
|
||||
SR&ED Manager Claim Report Generator | © <?= date('Y') ?>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
66
sred_claim_report_selector.php
Normal file
66
sred_claim_report_selector.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Fetch company settings to get fiscal year end
|
||||
$stmt = db()->prepare("SELECT fiscal_year_end FROM company_settings WHERE id = 1");
|
||||
$stmt->execute();
|
||||
$settings = $stmt->fetch();
|
||||
|
||||
$fiscal_year_end = $settings['fiscal_year_end'] ?? null;
|
||||
|
||||
// Determine possible years based on data
|
||||
$years = [];
|
||||
$res = db()->query("SELECT DISTINCT YEAR(entry_date) as y FROM labour_entries UNION SELECT DISTINCT YEAR(entry_date) as y FROM expenses ORDER BY y DESC");
|
||||
while($row = $res->fetch()) {
|
||||
$years[] = (int)$row['y'];
|
||||
}
|
||||
if (empty($years)) $years[] = (int)date('Y');
|
||||
|
||||
$pageTitle = "SR&ED Claim Report Selector";
|
||||
include __DIR__ . '/includes/header.php';
|
||||
?>
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body p-5 text-center">
|
||||
<i class="bi bi-file-earmark-bar-graph text-primary mb-4" style="font-size: 3rem;"></i>
|
||||
<h2 class="fw-bold mb-3">SRED Claim Report</h2>
|
||||
<p class="text-muted mb-4">Generate a comprehensive Scientific Research and Experimental Development (SR&ED) claim report for a specific fiscal year.</p>
|
||||
|
||||
<form action="sred_claim_report.php" method="GET">
|
||||
<div class="mb-4 text-start">
|
||||
<label class="form-label fw-bold small text-uppercase">Select Fiscal Year</label>
|
||||
<select name="year" class="form-select form-select-lg">
|
||||
<?php foreach($years as $y): ?>
|
||||
<option value="<?= $y ?>" <?= $y == date('Y') ? 'selected' : '' ?>><?= $y ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<?php if ($fiscal_year_end): ?>
|
||||
<div class="form-text mt-2">
|
||||
Your fiscal year ends on: <strong><?= date('F jS', strtotime($fiscal_year_end)) ?></strong>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="form-text mt-2 text-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i> Fiscal year end not set in <a href="company_settings.php">Company Preferences</a>.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
||||
Generate Report <i class="bi bi-arrow-right ms-2"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
115
system_preferences.php
Normal file
115
system_preferences.php
Normal file
@ -0,0 +1,115 @@
|
||||
<?php
|
||||
/**
|
||||
* System Preferences - Manage Password Requirements and Security
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/db/config.php';
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
Auth::requireLogin();
|
||||
|
||||
$tenant_id = (int)$_SESSION['tenant_id'];
|
||||
|
||||
// Handle Form Submission
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$prefs = [
|
||||
'pwd_min_length' => $_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';
|
||||
?>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="fw-bold mb-0">System Preferences</h2>
|
||||
<?php if (isset($_GET['success'])): ?>
|
||||
<span class="badge bg-success py-2 px-3">Preferences saved successfully</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">Password Requirements</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-bold small">Minimum Characters</label>
|
||||
<input type="number" name="pwd_min_length" class="form-control" value="<?= htmlspecialchars($prefs['pwd_min_length'] ?? '8') ?>" min="4" max="32">
|
||||
<div class="form-text extra-small text-muted">Passwords shorter than this will be rejected.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch p-3 border rounded">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="pwd_require_upper" id="reqUpper" <?= ($prefs['pwd_require_upper'] ?? '1') === '1' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold small" for="reqUpper">Require Uppercase Letters</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch p-3 border rounded">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="pwd_require_lower" id="reqLower" <?= ($prefs['pwd_require_lower'] ?? '1') === '1' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold small" for="reqLower">Require Lowercase Letters</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch p-3 border rounded">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="pwd_require_numbers" id="reqNumbers" <?= ($prefs['pwd_require_numbers'] ?? '1') === '1' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold small" for="reqNumbers">Require Numbers</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch p-3 border rounded">
|
||||
<input class="form-check-input ms-0 me-2" type="checkbox" name="pwd_no_common_words" id="noCommon" <?= ($prefs['pwd_no_common_words'] ?? '1') === '1' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label fw-bold small" for="noCommon">Don't Allow Common Words</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-3">
|
||||
<h5 class="mb-0 fw-bold">Authentication & 2FA</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-0">2FA settings are currently managed per-user in the Employee management section. Telephone numbers provided there will be used for SMS-based verification factors.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="settings.php" class="btn btn-light px-4">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary px-4">Save Preferences</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include __DIR__ . '/includes/footer.php'; ?>
|
||||
19
test_session.php
Normal file
19
test_session.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/auth_helper.php';
|
||||
|
||||
if (!isset($_SESSION['test_counter'])) {
|
||||
$_SESSION['test_counter'] = 0;
|
||||
}
|
||||
$_SESSION['test_counter']++;
|
||||
|
||||
echo "<h1>Session Diagnostic</h1>";
|
||||
echo "<b>Counter:</b> " . $_SESSION['test_counter'] . " (Refresh to see if it increases)<br>";
|
||||
echo "<b>Session ID:</b> " . session_id() . "<br>";
|
||||
echo "<b>HTTPS:</b> " . (isset($_SERVER['HTTPS']) ? $_SERVER['HTTPS'] : 'off') . "<br>";
|
||||
echo "<b>SameSite:</b> " . (session_get_cookie_params()['samesite'] ?? 'Not set') . "<br>";
|
||||
echo "<hr>";
|
||||
echo "<h3>If the counter doesn't increase on refresh:</h3>";
|
||||
echo "Your browser is likely blocking the session cookie because the preview is in an iframe. ";
|
||||
echo "I have set <code>SameSite=None; Secure</code> which should fix this, but some browsers require extra permissions or have strict privacy settings.";
|
||||
echo "<br><br><a href='test_session.php'>Click here to Refresh</a>";
|
||||
echo "<br><br><a href='login.php'>Go to Login</a>";
|
||||
BIN
uploads/69912b6e657db.pdf
Normal file
BIN
uploads/69912b6e657db.pdf
Normal file
Binary file not shown.
BIN
uploads/6991f572867cb.png
Normal file
BIN
uploads/6991f572867cb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
uploads/thumb_6991f572867cb.png
Normal file
BIN
uploads/thumb_6991f572867cb.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Loading…
x
Reference in New Issue
Block a user