Compare commits

...

10 Commits

Author SHA1 Message Date
Flatlogic Bot
9ff74420bd login update 2026-02-15 18:07:08 +00:00
Flatlogic Bot
36b8744143 Password changes 2026-02-15 17:59:20 +00:00
Flatlogic Bot
c8a94f29c5 company settings update 2026-02-15 17:29:16 +00:00
Flatlogic Bot
a2a8ca4262 import updated 2026-02-15 16:55:38 +00:00
Flatlogic Bot
5ba9886549 Import functions 2026-02-15 16:51:04 +00:00
Flatlogic Bot
0f6b05982a thumbnail improvement 2026-02-15 16:33:12 +00:00
Flatlogic Bot
cc5d6146bf file upload changes 2026-02-15 16:28:14 +00:00
Flatlogic Bot
b56a9a8592 Autosave: 20260215-155851 2026-02-15 15:58:51 +00:00
Flatlogic Bot
037f8c27be update to filtering 2026-02-15 15:54:39 +00:00
Flatlogic Bot
353a0f1f3b employee detail page 2026-02-15 15:19:05 +00:00
44 changed files with 3949 additions and 204 deletions

57
api/export_expenses.php Normal file
View 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
View 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
View 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()]);
}

View File

@ -15,13 +15,17 @@ try {
$db = db();
// Search Projects
$stmt = $db->prepare("SELECT id, name, code FROM projects WHERE name LIKE ? OR code LIKE ? LIMIT 5");
$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' => $row['name'] . ' (' . $row['code'] . ')',
'label' => $label,
'url' => 'project_detail.php?id=' . $row['id']
];
}

View File

@ -61,12 +61,48 @@ body {
.btn-primary {
background-color: #3b82f6;
border-color: #3b82f6;
color: #ffffff !important;
font-weight: 500;
}
.btn-primary:hover {
.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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

266
company_settings.php Normal file
View 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'; ?>

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

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

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

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

View File

@ -1,5 +1,7 @@
<?php
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$id = $_GET['id'] ?? null;
if (!$id) {
@ -8,7 +10,14 @@ if (!$id) {
}
$db = db();
$employee = $db->prepare("SELECT * FROM employees WHERE id = ?");
// 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();
@ -16,9 +25,77 @@ 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
@ -27,15 +104,61 @@ $stmt = $db->prepare("
JOIN labour_types lt ON l.labour_type_id = lt.id
WHERE l.employee_id = ?
ORDER BY l.entry_date DESC
LIMIT 20
LIMIT 10
");
$stmt->execute([$id]);
$entries = $stmt->fetchAll();
$labourEntries = $stmt->fetchAll();
// Fetch summary stats
$stats = $db->prepare("SELECT SUM(hours) as total_hours, COUNT(*) as entry_count FROM labour_entries WHERE employee_id = ?");
$stats->execute([$id]);
$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">
@ -43,69 +166,224 @@ $stats = $stats->fetch();
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="employees.php text-decoration-none">Employees</a></li>
<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"><?= htmlspecialchars($employee['position'] ?? 'Staff') ?></p>
<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">
<button class="btn btn-outline-primary btn-sm"><i class="bi bi-pencil me-1"></i> Edit Employee</button>
<?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">
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">Information</div>
<div class="card-body">
<div class="mb-3">
<label class="small text-muted d-block">Email</label>
<span><?= htmlspecialchars($employee['email'] ?? 'N/A') ?></span>
</div>
<div class="mb-3">
<label class="small text-muted d-block">Start Date</label>
<span><?= $employee['start_date'] ? date('M j, Y', strtotime($employee['start_date'])) : 'N/A' ?></span>
</div>
<div class="mb-0">
<label class="small text-muted d-block">Total Hours Logged</label>
<span class="fs-4 fw-bold text-primary"><?= number_format($stats['total_hours'] ?? 0, 1) ?></span>
<!-- 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>
<div class="col-md-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recent Labour Entries</span>
<a href="labour.php?employee_id=<?= $id ?>" class="btn btn-sm btn-link text-decoration-none">View All</a>
<!-- 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">
<thead>
<tr>
<th>Date</th>
<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>Hours</th>
<th>Notes</th>
<th class="text-end pe-3">Amount</th>
</tr>
</thead>
<tbody>
<?php if (empty($entries)): ?>
<tr>
<td colspan="5" class="text-center py-4 text-muted">No entries found for this employee.</td>
</tr>
<?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 ($entries as $e): ?>
<?php foreach ($expenseEntries as $ee): ?>
<tr>
<td><?= date('Y-m-d', strtotime($e['entry_date'])) ?></td>
<td><a href="project_detail.php?id=<?= $e['project_id'] ?>" class="text-decoration-none"><?= htmlspecialchars($e['project_name']) ?></a></td>
<td><?= htmlspecialchars($e['labour_type']) ?></td>
<td class="fw-bold text-primary"><?= number_format($e['hours'], 1) ?></td>
<td class="text-muted small"><?= htmlspecialchars(substr($e['notes'], 0, 50)) ?><?= strlen($e['notes']) > 50 ? '...' : '' ?></td>
<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; ?>

View File

@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Handle Add Employee
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) {
@ -12,24 +14,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) {
$position = $_POST['position'] ?? '';
$start_date = $_POST['start_date'] ?? date('Y-m-d');
$is_limited = isset($_POST['is_limited']) ? 1 : 0;
$phone = $_POST['phone'] ?? '';
$password = $_POST['password'] ?? '';
$force_password_change = isset($_POST['force_password_change']) ? 1 : 0;
$initial_wage = (float)($_POST['initial_wage'] ?? 0);
$team_ids = $_POST['teams'] ?? [];
if ($first_name && $last_name) {
$user_id = null;
if (!$is_limited && $email) {
$stmt = db()->prepare("INSERT IGNORE INTO users (tenant_id, name, email, role) VALUES (?, ?, ?, 'staff')");
$stmt->execute([$tenant_id, "$first_name $last_name", $email]);
$user_id = (int)db()->lastInsertId();
if ($user_id === 0) {
$stmt = db()->prepare("SELECT id FROM users WHERE email = ?");
$stmt->execute([$email]);
$user_id = (int)($stmt->fetchColumn() ?: null);
}
$hashed_password = $password ? password_hash($password, PASSWORD_DEFAULT) : null;
$stmt = db()->prepare("INSERT INTO users (tenant_id, name, email, phone, password, require_password_change, role)
VALUES (?, ?, ?, ?, ?, ?, 'staff')
ON DUPLICATE KEY UPDATE
phone = VALUES(phone),
password = COALESCE(VALUES(password), password),
require_password_change = VALUES(require_password_change)");
$stmt->execute([$tenant_id, "$first_name $last_name", $email, $phone, $hashed_password, $force_password_change]);
$stmt = db()->prepare("SELECT id FROM users WHERE email = ? AND tenant_id = ?");
$stmt->execute([$email, $tenant_id]);
$user_id = (int)($stmt->fetchColumn() ?: null);
}
$stmt = db()->prepare("INSERT INTO employees (tenant_id, first_name, last_name, email, position, start_date, is_limited, user_id, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$tenant_id, $first_name, $last_name, $email, $position, $start_date, $is_limited, $user_id, "$first_name $last_name"]);
$stmt = db()->prepare("INSERT INTO employees (tenant_id, first_name, last_name, email, phone, position, start_date, is_limited, user_id, name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$tenant_id, $first_name, $last_name, $email, $phone, $position, $start_date, $is_limited, $user_id, "$first_name $last_name"]);
$employee_id = (int)db()->lastInsertId();
if ($initial_wage > 0) {
@ -53,6 +62,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_employee'])) {
}
// Fetch Data
$stmt = db()->prepare("SELECT pref_key, pref_value FROM system_preferences WHERE tenant_id = ?");
$stmt->execute([$tenant_id]);
$prefs = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
$employees = db()->prepare("
SELECT e.*,
(SELECT hourly_rate FROM employee_wages WHERE employee_id = e.id ORDER BY effective_date DESC LIMIT 1) as current_wage
@ -103,7 +116,11 @@ include __DIR__ . '/includes/header.php';
<?php endif; ?>
<?php foreach ($employeeList as $e): ?>
<tr>
<td><strong><?= htmlspecialchars($e['first_name'] . ' ' . $e['last_name']) ?></strong></td>
<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
@ -118,7 +135,8 @@ include __DIR__ . '/includes/header.php';
<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">
<button class="btn btn-sm btn-outline-primary">Edit</button>
<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; ?>
@ -151,6 +169,10 @@ include __DIR__ . '/includes/header.php';
<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">
@ -174,12 +196,34 @@ include __DIR__ . '/includes/header.php';
<?php endforeach; ?>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="is_limited" id="limitedCheck" checked>
<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">
@ -190,4 +234,29 @@ include __DIR__ . '/includes/header.php';
</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'; ?>

View File

@ -1,22 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
// Get Projects for filter
$projects_stmt = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name ASC");
$projects_stmt->execute([$tenant_id]);
$all_projects = $projects_stmt->fetchAll();
$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;
@ -84,6 +96,12 @@ include __DIR__ . '/includes/header.php';
<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>

View File

@ -1,8 +1,11 @@
<?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 = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Handle Add Expense
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_expense'])) {
@ -32,8 +35,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_expense'])) {
if (!is_dir('uploads')) mkdir('uploads', 0775, true);
if (move_uploaded_file($tmp_name, $file_path)) {
$stmt = db()->prepare("INSERT INTO attachments (tenant_id, entity_type, entity_id, file_name, file_path, file_size, mime_type, uploaded_by) VALUES (?, 'expense', ?, ?, ?, ?, ?, 'John Manager')");
$stmt->execute([$tenant_id, $expense_id, $file_name, $file_path, $file_size, $mime_type]);
$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]);
}
}
}
@ -48,19 +59,46 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_expense'])) {
}
// 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 e.tenant_id = ?
WHERE $where_clause
ORDER BY e.entry_date DESC, e.created_at DESC
");
$expenseEntries->execute([$tenant_id]);
$expenseEntries->execute($params);
$expenseList = $expenseEntries->fetchAll();
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name");
$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();
@ -79,7 +117,52 @@ 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>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addExpenseModal">+ Add Expense</button>
<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'])): ?>
@ -122,7 +205,7 @@ include __DIR__ . '/includes/header.php';
<small class="extra-small text-muted"><?= (float)$ex['allocation_percent'] ?>% SR&ED</small>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary">Details</button>
<button class="btn btn-sm btn-secondary">Details</button>
</td>
</tr>
<?php endforeach; ?>

View File

@ -2,13 +2,15 @@
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 = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
$report_type = $_GET['report_type'] ?? 'labour_export';

View File

@ -1,22 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
// Get Projects for filter
$projects_stmt = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name ASC");
$projects_stmt->execute([$tenant_id]);
$all_projects = $projects_stmt->fetchAll();
$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;
@ -92,6 +104,12 @@ include __DIR__ . '/includes/header.php';
<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>

88
forgot_password.php Normal file
View 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
View 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
View 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
View 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
View 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) {}
}
}

View File

@ -15,12 +15,6 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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>
<style>
.nav-link.active {
color: #3b82f6 !important;
border-bottom: 2px solid #3b82f6;
}
</style>
</head>
<body>
@ -63,16 +57,29 @@ $currentPage = basename($_SERVER['PHP_SELF']);
<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']) ? 'active' : '' ?>" href="#" role="button" data-bs-toggle="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">
<a class="nav-link <?= $currentPage === 'settings.php' ? 'active' : '' ?>" href="settings.php">Settings</a>
<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">
@ -83,9 +90,20 @@ $currentPage = basename($_SERVER['PHP_SELF']);
</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="d-flex align-items-center text-muted small">
<span class="me-3">Tenant: <strong>Acme Research</strong></span>
<span class="badge bg-light text-dark border">Global Admin</span>
<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>

107
includes/media_helper.php Normal file
View 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/');
}

View File

@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Fetch Highlights Data
$projectHighlights = db()->prepare("

View File

@ -1,46 +1,92 @@
<?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 = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Handle Add Labour
// 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_id = (int)($_POST['employee_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 && $employee_id && $hours > 0) {
$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();
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
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;
// 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]);
}
if (!is_dir('uploads')) mkdir('uploads', 0775, true);
if (move_uploaded_file($tmp_name, $file_path)) {
$stmt = db()->prepare("INSERT INTO attachments (tenant_id, entity_type, entity_id, file_name, file_path, file_size, mime_type, uploaded_by) VALUES (?, 'labour_entry', ?, ?, ?, ?, ?, 'John Manager')");
$stmt->execute([$tenant_id, $labour_entry_id, $file_name, $file_path, $file_size, $mime_type]);
}
}
}
}
$stmt = db()->prepare("INSERT INTO activity_log (tenant_id, action, details) VALUES (?, ?, ?)");
$stmt->execute([$tenant_id, 'Labour Added', "Logged $hours hours for employee ID $employee_id"]);
$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;
@ -48,6 +94,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_labour'])) {
}
// 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
@ -55,13 +138,13 @@ $labourEntries = db()->prepare("
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 le.tenant_id = ?
WHERE $where_clause
ORDER BY le.entry_date DESC, le.created_at DESC
");
$labourEntries->execute([$tenant_id]);
$labourEntries->execute($params);
$labourList = $labourEntries->fetchAll();
$projects = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name");
$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();
@ -77,6 +160,28 @@ $evidenceTypes = db()->prepare("SELECT * FROM evidence_types WHERE tenant_id = ?
$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';
?>
@ -84,7 +189,71 @@ 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>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addLabourModal">+ Add Labour Entry</button>
<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'])): ?>
@ -124,7 +293,7 @@ include __DIR__ . '/includes/header.php';
</td>
<td class="small text-muted"><?= htmlspecialchars($l['notes'] ?? '') ?></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary">Details</button>
<button class="btn btn-sm btn-secondary">Details</button>
</td>
</tr>
<?php endforeach; ?>
@ -145,7 +314,48 @@ include __DIR__ . '/includes/header.php';
<form method="POST" enctype="multipart/form-data">
<div class="modal-body">
<div class="row">
<div class="col-md-6 mb-3">
<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>
@ -154,15 +364,6 @@ include __DIR__ . '/includes/header.php';
<?php endforeach; ?>
</select>
</div>
<div class="col-md-6 mb-3">
<label class="form-label small fw-bold">Employee</label>
<select name="employee_id" class="form-select" required>
<option value="">Select Employee...</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">Date</label>
<input type="date" name="entry_date" class="form-control" value="<?= date('Y-m-d') ?>" required>
@ -205,4 +406,230 @@ include __DIR__ . '/includes/header.php';
</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'; ?>

View File

@ -1,22 +1,34 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
// Get Projects for filter
$projects_stmt = db()->prepare("SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY name ASC");
$projects_stmt->execute([$tenant_id]);
$all_projects = $projects_stmt->fetchAll();
$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;
@ -84,6 +96,12 @@ include __DIR__ . '/includes/header.php';
<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>

78
login.php Normal file
View 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
View 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
View 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'; ?>

View File

@ -1,8 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Handle Add Project
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_project'])) {
@ -26,12 +28,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_project'])) {
}
}
// 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.*,
@ -42,6 +61,10 @@ $query = "
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%";
@ -86,7 +109,6 @@ include __DIR__ . '/includes/header.php';
<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-outline-secondary" onclick="toggleFilters()"><i class="bi bi-funnel"></i> Filter</button>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProjectModal">+ New Project</button>
</div>
</div>
@ -97,6 +119,68 @@ include __DIR__ . '/includes/header.php';
<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">
@ -126,7 +210,10 @@ include __DIR__ . '/includes/header.php';
?>
<tr>
<td>
<strong><?= htmlspecialchars($p['name']) ?></strong><br>
<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>
@ -142,7 +229,18 @@ include __DIR__ . '/includes/header.php';
</td>
<td><span class="status-badge status-<?= str_replace('_', '-', $p['status']) ?>"><?= ucfirst(str_replace('_', ' ', $p['status'])) ?></span></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary">Edit</button>
<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; ?>
@ -151,56 +249,6 @@ include __DIR__ . '/includes/header.php';
</div>
</div>
</div>
<!-- Filter Sidebar (Absolute or slide-in) -->
<div class="filter-sidebar shadow-sm" id="projectFilterSidebar" style="display:none; position: absolute; top: 0; right: 0; z-index: 1050; width: 300px;">
<div class="card border-0">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">FILTERS</span>
<button type="button" class="btn-close" onclick="toggleFilters()"></button>
</div>
<div class="card-body">
<form method="GET">
<div class="mb-3">
<label class="form-label small fw-bold">Search</label>
<input type="text" name="search" class="form-control form-control-sm" value="<?= htmlspecialchars($search) ?>">
</div>
<div class="mb-3">
<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="mb-3">
<label class="form-label small fw-bold">Start Date</label>
<select name="date_preset" class="form-select form-select-sm" onchange="handleDatePreset(this.value)">
<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 Range...</option>
</select>
</div>
<div id="customDateRange" class="<?= $date_preset === 'custom' ? '' : 'd-none' ?>">
<div class="mb-2">
<label class="form-label extra-small text-muted">From</label>
<input type="date" name="start_from" class="form-control form-control-sm" value="<?= htmlspecialchars($start_from) ?>">
</div>
<div class="mb-3">
<label class="form-label extra-small text-muted">To</label>
<input type="date" name="start_to" class="form-control form-control-sm" value="<?= htmlspecialchars($start_to) ?>">
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-sm btn-primary">Apply Filters</button>
<a href="projects.php" class="btn btn-sm btn-link text-decoration-none">Clear</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@ -258,10 +306,6 @@ include __DIR__ . '/includes/header.php';
</div>
<script>
function toggleFilters() {
const sidebar = document.getElementById('projectFilterSidebar');
sidebar.style.display = sidebar.style.display === 'none' ? 'block' : 'none';
}
function handleDatePreset(val) {
const custom = document.getElementById('customDateRange');
if (val === 'custom') {

View File

@ -1,9 +1,11 @@
<?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 = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
$report_type = $_GET['report_type'] ?? 'labour_export';
@ -115,6 +117,12 @@ include __DIR__ . '/includes/header.php';
<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'): ?>
@ -192,8 +200,9 @@ include __DIR__ . '/includes/header.php';
<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-outline-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>
<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">
@ -277,7 +286,7 @@ include __DIR__ . '/includes/header.php';
<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-outline-secondary" onclick="window.print()"><i class="bi bi-printer"></i> Print</button>
<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>

228
reports_media.php Normal file
View 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
View 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>

View 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
1 project_code supplier_name expense_type amount date notes
2 P001 ACME Corp Materials 1250.00 2026-02-10 Lumber for site A
3 P002 Build-It Co Subcontractors 5000.00 2026-02-12 Foundation work
4 P001 Global Telecom Overhead 150.00 2026-02-15 Monthly internet

View 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
1 project_code employee_email date hours labour_type evidence_type notes
2 PROJ-001 john.doe@example.com 2026-02-01 7.5 Experimental Development Logbooks Analysis of component A
3 PROJ-001 jane.smith@example.com 2026-02-01 4.0 Technical Support Test Results Testing component B
4 PROJ-002 john.doe@example.com 2026-02-02 6.0 Technical Planning Design Documents Drafting architecture

View 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
1 name type contact_info
2 ACME Corp supplier admin@acme.com
3 Build-It Co contractor contact@buildit.com
4 Global Telecom supplier info@globaltelecom.com

View File

@ -4,8 +4,10 @@
*/
declare(strict_types=1);
require_once __DIR__ . '/db/config.php';
require_once __DIR__ . '/includes/auth_helper.php';
Auth::requireLogin();
$tenant_id = 1;
$tenant_id = (int)$_SESSION['tenant_id'];
// Handle Form Submissions
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
@ -230,6 +232,44 @@ include __DIR__ . '/includes/header.php';
</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>

510
sred_claim_report.php Normal file
View 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 | &copy; <?= date('Y') ?>
</footer>
</body>
</html>

View 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
View 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
View 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/6991f572867cb.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB