Compare commits

..

4 Commits

Author SHA1 Message Date
Flatlogic Bot
e069921c28 Autosave: 20260228-223337 2026-02-28 22:33:39 +00:00
Flatlogic Bot
951a96262b Autosave: 20260228-194747 2026-02-28 19:47:47 +00:00
Flatlogic Bot
4349e548ed Autosave: 20260227-211046 2026-02-27 21:10:46 +00:00
Flatlogic Bot
6d06eea56a Initial Creation 2026-02-27 16:48:03 +00:00
46 changed files with 34267 additions and 118 deletions

BIN
R6 BOM.xlsx Normal file

Binary file not shown.

244
api/import_bom.php Normal file
View File

@ -0,0 +1,244 @@
<?php
session_start();
require_once __DIR__ . '/../db/config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['bomFile'])) {
http_response_code(400);
echo json_encode(['error' => 'No BOM file provided']);
exit;
}
$file = $_FILES['bomFile'];
if ($file['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'File upload error']);
exit;
}
$jobName = $_POST['jobName'] ?? pathinfo($file['name'], PATHINFO_FILENAME);
$existingJobId = $_POST['jobId'] ?? null;
$zip = new ZipArchive();
if ($zip->open($file['tmp_name']) !== TRUE) {
http_response_code(400);
echo json_encode(['error' => 'Invalid XLSX file format']);
exit;
}
try {
$db = db();
$db->beginTransaction();
// 1. Create or Select Job
if ($existingJobId) {
$stmt = $db->prepare("SELECT id FROM jobs WHERE id = ?");
$stmt->execute([$existingJobId]);
if (!$stmt->fetch()) throw new Exception("Job not found");
$jobId = $existingJobId;
} else {
$stmt = $db->prepare("INSERT INTO jobs (name, status) VALUES (?, 'planned')");
$stmt->execute([$jobName]);
$jobId = $db->lastInsertId();
}
// 2. Read Shared Strings
$sharedStringsRaw = $zip->getFromName('xl/sharedStrings.xml');
$strings = [];
if ($sharedStringsRaw) {
$xml = @simplexml_load_string($sharedStringsRaw);
if ($xml && isset($xml->si)) {
foreach ($xml->si as $si) {
$t = '';
if (isset($si->t)) $t = (string)$si->t;
elseif (isset($si->r)) foreach ($si->r as $r) $t .= (string)$r->t;
$strings[] = $t;
}
}
}
// 3. Extract Image Anchors (Row -> Image Path)
$rowImages = [];
$drawingRelsRaw = $zip->getFromName('xl/drawings/_rels/drawing1.xml.rels');
$rels = [];
if ($drawingRelsRaw) {
$xml = @simplexml_load_string($drawingRelsRaw);
if ($xml) {
foreach ($xml->Relationship as $rel) {
$rels[(string)$rel['Id']] = (string)$rel['Target'];
}
}
}
$drawingRaw = $zip->getFromName('xl/drawings/drawing1.xml');
if ($drawingRaw) {
$xml = @simplexml_load_string($drawingRaw);
if ($xml) {
$xml->registerXPathNamespace('xdr', 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing');
$xml->registerXPathNamespace('a', 'http://schemas.openxmlformats.org/drawingml/2006/main');
$anchors = $xml->xpath('//xdr:twoCellAnchor | //xdr:oneCellAnchor');
if ($anchors) {
foreach ($anchors as $anchor) {
$from = $anchor->xpath('.//xdr:from/xdr:row');
$blip = $anchor->xpath('.//a:blip');
if (!$from || !$blip) continue;
$row = (int)$from[0];
$embedAttr = $blip[0]->attributes('r', true);
if (!$embedAttr) {
// try to get without true for prefix if namespaced correctly in some versions
$embedAttr = $blip[0]->attributes('http://schemas.openxmlformats.org/officeDocument/2006/relationships');
}
if ($embedAttr && isset($embedAttr['embed'])) {
$rId = (string)$embedAttr['embed'];
if (isset($rels[$rId])) {
$target = str_replace('../', 'xl/', $rels[$rId]);
$rowImages[$row] = $target; // $row is 0-indexed in XML
}
}
}
}
}
}
// 4. Read Rows
$sheetRaw = $zip->getFromName('xl/worksheets/sheet1.xml');
if (!$sheetRaw) throw new Exception("Could not find sheet1.xml");
$xml = @simplexml_load_string($sheetRaw);
if (!$xml) throw new Exception("Failed to parse sheet1.xml");
$headerMap = [];
$componentsMap = []; // '1.6' => db id
$rowIndex = 0;
$stmtComp = $db->prepare("
INSERT INTO components (job_id, parent_id, name, type, quantity, thickness, material, notes, thumbnail_data, order_index)
VALUES (?, ?, ?, 'part', ?, ?, ?, ?, ?, ?)
");
if (isset($xml->sheetData->row)) {
foreach ($xml->sheetData->row as $row) {
$rowData = [];
$colIndex = 0;
// Handle sparse columns
foreach ($row->c as $c) {
// Excel cell reference like "A1", "C2"
$cellRef = (string)$c['r'];
$colLetter = preg_replace('/[0-9]/', '', $cellRef);
// Convert colLetter (A, B, C...) to index (0, 1, 2...)
$colNum = 0;
$len = strlen($colLetter);
for ($i = 0; $i < $len; $i++) {
$colNum = $colNum * 26 + (ord($colLetter[$i]) - 64);
}
$colIndex = $colNum - 1;
$val = (string)$c->v;
if (isset($c['t']) && $c['t'] == 's') {
$val = $strings[(int)$val] ?? $val;
}
$rowData[$colIndex] = $val;
}
// Ensure all indexes exist up to max column
if (!empty($rowData)) {
$maxKey = max(array_keys($rowData));
for ($i = 0; $i <= $maxKey; $i++) {
if (!isset($rowData[$i])) $rowData[$i] = '';
}
ksort($rowData);
}
if ($rowIndex === 0) {
foreach ($rowData as $idx => $val) {
$headerMap[trim(strtolower($val))] = $idx;
}
} else if (!empty($rowData)) {
// Data row
$itemIdx = $headerMap['item'] ?? 0;
$item = $rowData[$itemIdx] ?? '';
$partIdx = $headerMap['part number'] ?? ($headerMap['filename'] ?? -1);
$partNumber = $partIdx >= 0 ? ($rowData[$partIdx] ?? 'Unknown Part') : 'Unknown Part';
if (empty($item) && $partNumber === 'Unknown Part') {
$rowIndex++;
continue; // Skip empty
}
$qtyIdx = $headerMap['qty'] ?? 6;
$qty = $rowData[$qtyIdx] ?? 1;
$thickIdx = $headerMap['thickness'] ?? -1;
$thickness = $thickIdx >= 0 ? ($rowData[$thickIdx] ?? '') : '';
$thickness = str_replace(' in', '"', trim($thickness)); // convert inches
$matIdx = $headerMap['material'] ?? -1;
$material = $matIdx >= 0 ? ($rowData[$matIdx] ?? '') : '';
$descIdx = $headerMap['description'] ?? -1;
$notes = $descIdx >= 0 ? ($rowData[$descIdx] ?? '') : '';
// Thumbnail
$thumbnailData = null;
if (isset($rowImages[$rowIndex])) {
$imgData = $zip->getFromName($rowImages[$rowIndex]);
if ($imgData) {
// guess mime type based on extension
$ext = strtolower(pathinfo($rowImages[$rowIndex], PATHINFO_EXTENSION));
$mime = ($ext == 'png') ? 'image/png' : 'image/jpeg';
$thumbnailData = "data:$mime;base64," . base64_encode($imgData);
}
}
// Figure out parent
$parentId = null;
$parts = explode('.', (string)$item);
if (count($parts) > 1) {
array_pop($parts); // remove last segment
$parentItem = implode('.', $parts);
if (isset($componentsMap[$parentItem])) {
$parentId = $componentsMap[$parentItem];
}
}
$stmtComp->execute([
$jobId,
$parentId,
$partNumber,
(int)$qty,
$thickness,
$material,
$notes,
$thumbnailData,
$rowIndex // order index
]);
$compId = $db->lastInsertId();
$componentsMap[(string)$item] = $compId;
}
$rowIndex++;
}
}
$zip->close();
$db->commit();
echo json_encode(['success' => true, 'job_id' => $jobId, 'message' => "Imported successfully!"]);
} catch (Exception $e) {
if (isset($db)) $db->rollBack();
http_response_code(500);
echo json_encode(['error' => 'Import failed: ' . $e->getMessage()]);
}

115
api/ops.php Normal file
View File

@ -0,0 +1,115 @@
<?php
session_start();
require '../db/config.php';
$db = db();
if (!isset($_SESSION['user_id'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Unauthorized']);
exit;
}
$userId = $_SESSION['user_id'];
$data = json_decode(file_get_contents('php://input'), true);
if (!$data || !isset($data['opId']) || !isset($data['action'])) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Invalid data']);
exit;
}
$opId = $data['opId'];
$action = $data['action'];
$reason = $data['reason'] ?? null;
try {
$db->beginTransaction();
// Fetch current op state
$stmt = $db->prepare("SELECT * FROM operations WHERE id = ?");
$stmt->execute([$opId]);
$op = $stmt->fetch();
if (!$op) {
throw new Exception("Operation not found");
}
$eventType = '';
$statusUpdate = '';
$now = date('Y-m-d H:i:s');
switch ($action) {
case 'start':
$statusUpdate = "status = 'in_progress', assigned_worker_id = $userId, start_time = IFNULL(start_time, '$now')";
$eventType = 'start';
// If it was stalled, event type is 'resume'
if ($op['status'] === 'stalled') {
$eventType = 'resume';
}
break;
case 'stall':
$statusUpdate = "status = 'stalled', stall_reason = " . $db->quote($reason);
$eventType = 'stalled';
break;
case 'done':
$statusUpdate = "status = 'completed', end_time = '$now'";
$eventType = 'completed';
break;
default:
throw new Exception("Invalid action");
}
// Update operation
$db->exec("UPDATE operations SET $statusUpdate WHERE id = $opId");
// Log time study event
$stmt = $db->prepare("INSERT INTO time_study_events (operation_id, user_id, event_type, reason) VALUES (?, ?, ?, ?)");
$stmt->execute([$opId, $userId, $eventType, $reason]);
// Post-completion logic (if done)
if ($action === 'done') {
// Check if all operations for this component are done
$compId = $op['component_id'];
$pendingOps = $db->prepare("SELECT COUNT(*) FROM operations WHERE component_id = ? AND status != 'completed'");
$pendingOps->execute([$compId]);
if ($pendingOps->fetchColumn() == 0) {
// Component is done
$db->exec("UPDATE components SET status = 'completed' WHERE id = $compId");
// Check if all components for this job are done
$stmt = $db->prepare("SELECT job_id FROM components WHERE id = ?");
$stmt->execute([$compId]);
$jobId = $stmt->fetchColumn();
$pendingComps = $db->prepare("SELECT COUNT(*) FROM components WHERE job_id = ? AND status != 'completed'");
$pendingComps->execute([$jobId]);
if ($pendingComps->fetchColumn() == 0) {
// Job is done
$db->exec("UPDATE jobs SET status = 'completed' WHERE id = $jobId");
} else {
// Job is in_progress if not already
$db->exec("UPDATE jobs SET status = 'in_progress' WHERE id = $jobId");
}
} else {
// Component is in_progress if not already
$db->exec("UPDATE components SET status = 'in_progress' WHERE id = $compId");
// Job is in_progress
$stmt = $db->prepare("SELECT job_id FROM components WHERE id = ?");
$stmt->execute([$compId]);
$jobId = $stmt->fetchColumn();
$db->exec("UPDATE jobs SET status = 'in_progress' WHERE id = $jobId");
}
}
$db->commit();
echo json_encode(['success' => true]);
} catch (Exception $e) {
$db->rollBack();
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}

49
api/test_bom_parser.php Normal file
View File

@ -0,0 +1,49 @@
<?php
$zip = new ZipArchive();
if ($zip->open(__DIR__ . "/../R6 BOM.xlsx") === TRUE) {
// 1. Get drawing relationships
$drawingRelsRaw = $zip->getFromName('xl/drawings/_rels/drawing1.xml.rels');
$rels = [];
if ($drawingRelsRaw) {
$xml = simplexml_load_string($drawingRelsRaw);
foreach ($xml->Relationship as $rel) {
$rels[(string)$rel['Id']] = (string)$rel['Target'];
}
}
// 2. Get drawing anchors to map row -> image path
$drawingRaw = $zip->getFromName('xl/drawings/drawing1.xml');
$rowImages = [];
if ($drawingRaw) {
$xml = simplexml_load_string($drawingRaw);
$xml->registerXPathNamespace('xdr', 'http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing');
$xml->registerXPathNamespace('a', 'http://schemas.openxmlformats.org/drawingml/2006/main');
$xml->registerXPathNamespace('r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships');
$anchors = $xml->xpath('//xdr:twoCellAnchor | //xdr:oneCellAnchor');
foreach ($anchors as $anchor) {
$from = $anchor->xpath('.//xdr:from/xdr:row');
if (!$from) continue;
$row = (int)$from[0];
$blip = $anchor->xpath('.//a:blip');
if (!$blip) continue;
$embedAttr = $blip[0]->attributes('r', true);
$rId = (string)$embedAttr['embed'];
if (isset($rels[$rId])) {
$target = $rels[$rId]; // e.g. ../media/image1.jpg
$target = str_replace('../', 'xl/', $target);
$rowImages[$row] = $target; // 0-indexed row
}
}
}
// Check first 5 row images
foreach (array_slice($rowImages, 0, 5, true) as $r => $img) {
echo "Row " . ($r + 1) . " has image $img\n";
}
$zip->close();
}

34
api/test_xlsx.php Normal file
View File

@ -0,0 +1,34 @@
<?php
$zip = new ZipArchive();
if ($zip->open(__DIR__ . "/../R6 BOM.xlsx") === TRUE) {
$sharedStringsRaw = $zip->getFromName('xl/sharedStrings.xml');
$strings = [];
if ($sharedStringsRaw) {
$xml = simplexml_load_string($sharedStringsRaw);
foreach ($xml->si as $si) {
$t = '';
if (isset($si->t)) $t = (string)$si->t;
elseif (isset($si->r)) foreach ($si->r as $r) $t .= (string)$r->t;
$strings[] = $t;
}
}
$sheetRaw = $zip->getFromName('xl/worksheets/sheet1.xml');
$xml = simplexml_load_string($sheetRaw);
$rows = 0;
foreach ($xml->sheetData->row as $row) {
$rowData = [];
foreach ($row->c as $c) {
$val = (string)$c->v;
if (isset($c['t']) && $c['t'] == 's') {
$val = $strings[(int)$val];
}
$rowData[] = $val;
}
echo implode(" | ", $rowData) . "\n";
$rows++;
if ($rows > 5) break;
}
$zip->close();
}

90
auth.php Normal file
View File

@ -0,0 +1,90 @@
<?php
session_start();
require 'db/config.php';
function loginWorker($userId) {
$db = db();
$stmt = $db->prepare("SELECT * FROM users WHERE id = ? AND role = 'worker'");
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['role'] = 'worker';
return true;
}
return false;
}
function loginAdmin($userId, $pin) {
$db = db();
$stmt = $db->prepare("SELECT * FROM users WHERE id = ? AND role = 'admin'");
$stmt->execute([$userId]);
$user = $stmt->fetch();
if ($user) {
// If no PIN set, allow setup (bootstrap mode)
if ($user['pin_hash'] === null) {
// This is special case, first time login
$_SESSION['pending_setup_user_id'] = $user['id'];
return 'setup';
}
if (password_verify($pin, $user['pin_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['role'] = 'admin';
return true;
}
}
return false;
}
function setupAdminPin($userId, $pin) {
$db = db();
$hash = password_hash($pin, PASSWORD_BCRYPT);
$stmt = $db->prepare("UPDATE users SET pin_hash = ? WHERE id = ? AND role = 'admin'");
return $stmt->execute([$hash, $userId]);
}
// Check POST requests
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = $_POST['action'] ?? '';
if ($action === 'login_worker') {
if (loginWorker($_POST['user_id'])) {
header('Location: dashboard.php');
exit;
}
} elseif ($action === 'login_admin') {
$res = loginAdmin($_POST['user_id'], $_POST['pin']);
if ($res === true) {
header('Location: dashboard.php');
exit;
} elseif ($res === 'setup') {
header('Location: index.php?setup=1');
exit;
}
} elseif ($action === 'setup_pin') {
$userId = $_SESSION['pending_setup_user_id'] ?? null;
if ($userId && !empty($_POST['pin'])) {
if (setupAdminPin($userId, $_POST['pin'])) {
unset($_SESSION['pending_setup_user_id']);
// Auto login after setup
$db = db();
$stmt = $db->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$userId]);
$user = $stmt->fetch();
$_SESSION['user_id'] = $user['id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['role'] = 'admin';
header('Location: dashboard.php');
exit;
}
}
}
header('Location: index.php?error=1');
exit;
}

26
batch_task_schema.json Normal file
View File

@ -0,0 +1,26 @@
{
"groupKey": "rolling|MCR Steel|0.063 in|7",
"processType": "rolling",
"material": "MCR Steel",
"thickness": "0.063 in",
"jobId": 7,
"jobName": "R6",
"customerName": "Gillman",
"serialNumber": "9876",
"mainAssembly": "Chaff Collector",
"subAssembly": "Frame",
"operations": [
{
"operationId": 53,
"componentId": 1379,
"componentName": "Frame- Cylinder Upper",
"quantity": 1,
"status": "pending",
"stallReason": null,
"assignedTo": null
}
],
"totalCount": 1,
"pendingCount": 1,
"completedCount": 0
}

259
bom_import.php Normal file
View File

@ -0,0 +1,259 @@
<?php
session_start();
require 'db/config.php';
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
header('Location: index.php');
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M-TRACK | Import BOM</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--bg: #f8fafc;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.sidebar {
width: var(--sidebar-width);
position: fixed;
top: 0;
left: 0;
height: 100vh;
background: var(--primary);
color: white;
padding: 2rem 1rem;
overflow-y: auto;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2.5rem;
}
.sidebar h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 2rem;
padding: 0 0.5rem;
letter-spacing: -0.025em;
}
.nav-pills .nav-link {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
padding: 0.625rem 0.75rem;
margin-bottom: 0.25rem;
border-radius: 4px;
}
.nav-pills .nav-link:hover {
color: white;
background-color: rgba(255,255,255,0.05);
}
.nav-pills .nav-link.active {
color: white;
background-color: var(--accent);
}
.drop-zone {
border: 2px dashed #3b82f6;
border-radius: 10px;
padding: 40px;
text-align: center;
background: white;
cursor: pointer;
transition: all 0.3s ease;
}
.drop-zone.dragover {
background: #eff6ff;
border-color: #2563eb;
}
.drop-zone i {
font-size: 3rem;
color: #3b82f6;
}
#file-input { display: none; }
.card { border: 1px solid var(--border); box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div class="sidebar">
<h2>M-TRACK</h2>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<a class="nav-link active" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<a class="nav-link" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Import BOM</h1>
<a href="jobs.php" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back to Jobs</a>
</div>
<div class="row">
<div class="col-md-8 col-lg-6">
<div class="card bg-white p-4">
<?php $existingJobId = $_GET["job_id"] ?? ""; ?>
<form id="bomImportForm">
<?php if ($existingJobId): ?>
<input type="hidden" id="jobId" value="<?= htmlspecialchars($existingJobId) ?>">
<div class="alert alert-info">
<strong><i class="bi bi-info-circle"></i> Importing into existing job (ID: <?= htmlspecialchars($existingJobId) ?>)</strong>
</div>
<?php else: ?>
<div class="mb-3">
<label class="form-label fw-bold">Job Name (Optional)</label>
<input type="text" id="jobName" class="form-control" placeholder="Leave blank to use filename">
</div>
<?php endif; ?>
<div class="mb-4">
<label class="form-label fw-bold">BOM Excel File (.xlsx)</label>
<div class="drop-zone" id="dropZone" onclick="document.getElementById('file-input').click()">
<i class="bi bi-cloud-arrow-up mb-3 d-block"></i>
<h5>Click to select or drag and drop here</h5>
<p class="text-muted mb-0">Only .xlsx files are supported</p>
<input type="file" id="file-input" accept=".xlsx" required>
</div>
<div id="file-name-display" class="mt-2 text-primary fw-bold text-center" style="display:none;"></div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg" id="uploadBtn" disabled>
<i class="bi bi-upload"></i> Import BOM Now
</button>
</div>
</form>
<div id="uploadStatus" class="mt-4" style="display:none;">
<div class="alert alert-info d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-3" role="status"></div>
<div>Parsing Excel file and extracting images natively... Please wait.</div>
</div>
</div>
<div id="uploadError" class="mt-4 alert alert-danger" style="display:none;"></div>
<div id="uploadSuccess" class="mt-4 alert alert-success" style="display:none;"></div>
</div>
<div class="card mt-4 p-3 bg-light text-muted small">
<h6 class="fw-bold mb-2"><i class="bi bi-info-circle text-info"></i> How it works</h6>
<ul class="mb-0 ps-3">
<li>The system parses the <strong>Item</strong> column (e.g. 1, 1.6, 1.6.1) to build the assembly tree perfectly matching the Replit behavior.</li>
<li>Extracts standard fields like Part Number, QTY, Thickness, Material, and Description.</li>
<li>Natively extracts embedded thumbnail images from the Excel archive and attaches them!</li>
</ul>
</div>
</div>
</div>
</div>
<script>
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('dropZone');
const fileNameDisplay = document.getElementById('file-name-display');
const uploadBtn = document.getElementById('uploadBtn');
const form = document.getElementById('bomImportForm');
const uploadStatus = document.getElementById('uploadStatus');
const uploadError = document.getElementById('uploadError');
const uploadSuccess = document.getElementById('uploadSuccess');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
fileInput.files = e.dataTransfer.files;
handleFileSelect();
}
});
fileInput.addEventListener('change', handleFileSelect);
function handleFileSelect() {
if (fileInput.files.length > 0) {
fileNameDisplay.textContent = 'Selected: ' + fileInput.files[0].name;
fileNameDisplay.style.display = 'block';
uploadBtn.disabled = false;
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (fileInput.files.length === 0) return;
uploadBtn.disabled = true;
uploadStatus.style.display = 'block';
uploadError.style.display = 'none';
uploadSuccess.style.display = 'none';
const formData = new FormData();
formData.append('bomFile', fileInput.files[0]);
const jobIdEl = document.getElementById('jobId');
if (jobIdEl) {
formData.append('jobId', jobIdEl.value);
}
const jobNameEl = document.getElementById('jobName');
if (jobNameEl && jobNameEl.value.trim()) {
formData.append('jobName', jobNameEl.value.trim());
}
try {
const response = await fetch('api/import_bom.php', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
uploadStatus.style.display = 'none';
uploadSuccess.textContent = result.message + ' Redirecting...';
uploadSuccess.style.display = 'block';
setTimeout(() => {
window.location.href = 'jobs.php?id=' + result.job_id;
}, 1500);
} else {
throw new Error(result.error || 'Upload failed');
}
} catch (err) {
uploadStatus.style.display = 'none';
uploadBtn.disabled = false;
uploadError.textContent = err.message;
uploadError.style.display = 'block';
}
});
</script>
</body>
</html>

19
component_schema.json Normal file
View File

@ -0,0 +1,19 @@
{
"id": 1,
"name": "Steel Tube cutting",
"type": "machining",
"processes": [],
"status": "completed",
"quantity": 1,
"jobId": 1,
"parentId": null,
"assignedTo": "1571ac82-0762-4763-83e7-a216a6f3ed82",
"notes": "Cut to 1500mm length",
"thickness": null,
"material": null,
"thumbnailData": null,
"blueprintUrl": null,
"orderIndex": 0,
"priorityWeight": 50,
"readyForAssembly": false
}

1
components.json Normal file

File diff suppressed because one or more lines are too long

1
consumables.json Normal file
View File

@ -0,0 +1 @@
[{"id":1,"name":"TIG Welding Rods","quantity":50,"reorderLevel":20,"unit":"kg"}]

5
cookies.txt Normal file
View File

@ -0,0 +1,5 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
assembly-flow--alexmillcity.replit.app FALSE / FALSE 1774821327 GAESA Cp4BMDBkYTZjZDJjNDcxZmM2Y2MwMWJiZWUzMGRjNDQwM2E5MGIzMmM2MGJmZGFlZGY2NmNjM2ExMzE0NTJlNjgyYmJhOWU3YTY3Y2U3YTRkOWU3ZTA4ZGM4N2M3ZTYyNmU1NmNlNGM3MjkzZDI1ZGFhMjcwZjlkOGVjOWVmZTEwOGMyMTRhNmRlMzZjYzQ3OWVlNGVhZGUxNDE0ZTI3ZDgQo_jliMoz

464
dashboard.php Normal file
View File

@ -0,0 +1,464 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
$role = $_SESSION['role'];
$userName = $_SESSION['user_name'];
$userId = $_SESSION['user_id'];
// Get assigned processes for worker
$assignedProcesses = [];
if ($role === 'worker') {
$stmt = $db->prepare("SELECT assigned_processes FROM users WHERE id = ?");
$stmt->execute([$userId]);
$res = $stmt->fetch();
$assignedProcesses = json_decode($res['assigned_processes'] ?? '[]', true);
}
// Stats for Admin
$stats = [
'active_jobs' => 0,
'pending_ops' => 0,
'inventory_alerts' => 0,
'active_workers' => 0
];
if ($role === 'admin') {
$stats['active_jobs'] = $db->query("SELECT COUNT(*) FROM jobs WHERE status = 'in_progress'")->fetchColumn();
$stats['pending_ops'] = $db->query("SELECT COUNT(*) FROM operations WHERE status = 'pending'")->fetchColumn();
$stats['inventory_alerts'] = $db->query("SELECT COUNT(*) FROM inventory WHERE stock_level <= reorder_level")->fetchColumn();
$stats['active_workers'] = $db->query("SELECT COUNT(DISTINCT assigned_worker_id) FROM operations WHERE status = 'in_progress'")->fetchColumn();
// Fetch recent jobs
$recentJobs = $db->query("SELECT * FROM jobs ORDER BY created_at DESC LIMIT 5")->fetchAll();
}
// Queue for Worker or Admin View
$queue = [];
if ($role === 'worker') {
if (!empty($assignedProcesses)) {
$placeholders = implode(',', array_fill(0, count($assignedProcesses), '?'));
$stmt = $db->prepare("
SELECT o.*, c.name as component_name, j.name as job_name
FROM operations o
JOIN components c ON o.component_id = c.id
JOIN jobs j ON c.job_id = j.id
WHERE o.status IN ('pending', 'in_progress', 'stalled')
AND o.process_type IN ($placeholders)
ORDER BY o.priority DESC, o.created_at ASC
");
$stmt->execute($assignedProcesses);
$queue = $stmt->fetchAll();
}
} else {
// Admin sees all in-progress or stalled ops
$queue = $db->query("
SELECT o.*, c.name as component_name, j.name as job_name, u.name as worker_name
FROM operations o
JOIN components c ON o.component_id = c.id
JOIN jobs j ON c.job_id = j.id
LEFT JOIN users u ON o.assigned_worker_id = u.id
WHERE o.status IN ('in_progress', 'stalled')
ORDER BY o.created_at ASC
")->fetchAll();
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Dashboard</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--bg: #f8fafc;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.sidebar {
width: var(--sidebar-width);
position: fixed;
top: 0;
bottom: 0;
left: 0;
background-color: var(--primary);
color: white;
z-index: 1000;
padding: 1.5rem 1rem;
}
.sidebar h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 2rem;
padding: 0 0.5rem;
letter-spacing: -0.025em;
}
.nav-pills .nav-link {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
padding: 0.625rem 0.75rem;
margin-bottom: 0.25rem;
border-radius: 4px;
}
.nav-pills .nav-link:hover {
color: white;
background-color: rgba(255,255,255,0.05);
}
.nav-pills .nav-link.active {
color: white;
background-color: var(--accent);
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2.5rem;
}
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.top-bar h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary);
margin: 0;
}
.user-pill {
background: white;
border: 1px solid var(--border);
padding: 0.375rem 1rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card {
background: white;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
margin-bottom: 1.5rem;
}
.card-header {
background: white;
border-bottom: 1px solid var(--border);
padding: 1rem 1.25rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--primary);
}
.badge-process {
background: #e2e8f0;
color: #475569;
font-weight: 600;
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
border-radius: 4px;
margin-right: 0.5rem;
}
.op-card {
padding: 1.25rem;
border-left: 4px solid #cbd5e1;
transition: transform 0.1s;
}
.op-card:hover {
transform: translateY(-2px);
}
.op-card.status-in_progress { border-left-color: var(--success); }
.op-card.status-stalled { border-left-color: var(--warning); }
.op-card.status-pending { border-left-color: #cbd5e1; }
.op-meta {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
font-weight: 600;
}
.op-title {
font-weight: 700;
font-size: 1rem;
color: var(--primary);
margin-bottom: 0.5rem;
}
.op-job {
font-size: 0.875rem;
color: #64748b;
}
.btn-action {
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
padding: 0.5rem 1rem;
}
</style>
</head>
<body>
<div class="sidebar">
<h2>M-TRACK</h2>
<nav class="nav nav-pills flex-column">
<a class="nav-link active" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<?php if ($role === 'admin'): ?>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<a class="nav-link" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<?php else: ?>
<a class="nav-link" href="dashboard.php"><i class="bi bi-list-task me-2"></i> My Queue</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Board View</a>
<a class="nav-link" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<?php endif; ?>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="top-bar">
<h1><?= $role === 'admin' ? 'Operations Overview' : 'Work Queue' ?></h1>
<div class="user-pill">
<i class="bi bi-person-circle text-muted"></i>
<?= htmlspecialchars($userName) ?>
<span class="text-muted text-uppercase" style="font-size: 0.625rem;"><?= $role ?></span>
</div>
</div>
<?php if ($role === 'worker'): ?>
<div class="mb-4 d-flex align-items-center">
<span class="text-muted small fw-bold text-uppercase me-3">My Skills:</span>
<?php foreach ($assignedProcesses as $proc): ?>
<span class="badge-process"><?= strtoupper($proc) ?></span>
<?php endforeach; ?>
</div>
<div class="row">
<?php if (empty($queue)): ?>
<div class="col-12">
<div class="card card-body py-5 text-center">
<div class="mb-3 text-muted" style="font-size: 2rem;"><i class="bi bi-clipboard-check"></i></div>
<h5 class="text-muted">No pending operations for your assigned processes.</h5>
</div>
</div>
<?php else: ?>
<?php foreach ($queue as $op): ?>
<div class="col-md-6 col-xl-4">
<div class="card op-card status-<?= $op['status'] ?>">
<div class="op-meta"><?= $op['process_type'] ?> • Priority <?= $op['priority'] ?></div>
<div class="op-title"><?= htmlspecialchars($op['name']) ?></div>
<div class="op-job mb-3">
<i class="bi bi-box-seam me-1"></i> <?= htmlspecialchars($op['job_name']) ?> / <?= htmlspecialchars($op['component_name']) ?>
</div>
<div class="d-flex gap-2">
<?php if ($op['status'] === 'pending' || $op['status'] === 'stalled'): ?>
<button class="btn btn-success btn-action w-100" onclick="handleOp(<?= $op['id'] ?>, 'start')">
<?= $op['status'] === 'stalled' ? 'Resume' : 'Start' ?>
</button>
<?php endif; ?>
<?php if ($op['status'] === 'in_progress'): ?>
<button class="btn btn-warning btn-action w-50" onclick="handleOp(<?= $op['id'] ?>, 'stall')">Stall</button>
<button class="btn btn-primary btn-action w-50" onclick="handleOp(<?= $op['id'] ?>, 'done')">Done</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php else: ?>
<!-- Admin View -->
<div class="row">
<div class="col-md-3">
<div class="card text-center p-3">
<div class="text-muted small fw-bold text-uppercase mb-1">Active Jobs</div>
<div class="h3 fw-bold mb-0"><?= $stats['active_jobs'] ?></div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center p-3">
<div class="text-muted small fw-bold text-uppercase mb-1">Pending Ops</div>
<div class="h3 fw-bold mb-0"><?= $stats['pending_ops'] ?></div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center p-3">
<div class="text-muted small fw-bold text-uppercase mb-1">Inventory Alerts</div>
<div class="h3 fw-bold mb-0 text-<?= $stats['inventory_alerts'] > 0 ? 'danger' : 'success' ?>">
<?= $stats['inventory_alerts'] ?>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center p-3">
<div class="text-muted small fw-bold text-uppercase mb-1">Active Workers</div>
<div class="h3 fw-bold mb-0"><?= $stats['active_workers'] ?></div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
Live Production Status
<span class="badge bg-primary rounded-pill"><?= count($queue) ?> Active Ops</span>
</div>
<div class="card-body p-0">
<?php if (empty($queue)): ?>
<div class="p-5 text-center text-muted">No operations currently in progress.</div>
<?php else: ?>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Operation</th>
<th>Worker</th>
<th>Status</th>
<th>Started</th>
</tr>
</thead>
<tbody>
<?php foreach ($queue as $op): ?>
<tr>
<td>
<div class="fw-bold"><?= htmlspecialchars($op['name']) ?></div>
<div class="small text-muted"><?= htmlspecialchars($op['job_name']) ?></div>
</td>
<td><?= htmlspecialchars($op['worker_name'] ?? 'Unassigned') ?></td>
<td>
<span class="badge bg-<?= $op['status'] === 'in_progress' ? 'success' : 'warning' ?>">
<?= strtoupper($op['status']) ?>
</span>
</td>
<td class="small"><?= date('H:i', strtotime($op['start_time'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">Recent Jobs</div>
<div class="card-body p-0">
<div class="list-group list-group-flush">
<?php foreach ($recentJobs as $job): ?>
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold"><?= htmlspecialchars($job['name']) ?></span>
<span class="badge bg-light text-dark border small"><?= $job['status'] ?></span>
</div>
<div class="small text-muted">Due: <?= $job['due_date'] ?> • Qty: <?= $job['quantity'] ?></div>
</div>
<?php endforeach; ?>
<?php if (empty($recentJobs)): ?>
<div class="p-4 text-center text-muted">No jobs created yet.</div>
<?php endif; ?>
</div>
</div>
<div class="card-footer bg-white">
<a href="jobs.php" class="btn btn-sm btn-outline-primary w-100">Manage Jobs</a>
</div>
</div>
</div>
</div>
<?php endif; ?>
</div>
<!-- Modal for Stall Reason -->
<div class="modal fade" id="stallModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Stall Reason</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<textarea id="stallReason" class="form-control" placeholder="Explain why work is stalling (e.g., missing hardware, machine down)..."></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" onclick="confirmStall()">Confirm Stall</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
let currentOpId = null;
const stallModal = new bootstrap.Modal(document.getElementById('stallModal'));
function handleOp(opId, action) {
if (action === 'stall') {
currentOpId = opId;
stallModal.show();
return;
}
if (action === 'done' && !confirm('Mark this operation as complete?')) return;
performAction(opId, action);
}
function confirmStall() {
const reason = document.getElementById('stallReason').value.trim();
if (!reason) {
alert('Please provide a reason for stalling.');
return;
}
performAction(currentOpId, 'stall', reason);
stallModal.hide();
}
function performAction(opId, action, reason = '') {
fetch('api/ops.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ opId, action, reason })
})
.then(r => r.json())
.then(res => {
if (res.success) {
location.reload();
} else {
alert('Error: ' + res.error);
}
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,162 @@
<?php
require_once __DIR__ . '/config.php';
$db = db();
try {
// Disable foreign key checks for dropping tables
$db->exec("SET FOREIGN_KEY_CHECKS=0");
// Drop existing tables if we want a clean slate
$tables = ['time_study_events', 'inventory_transactions', 'inventory', 'operations', 'components', 'jobs', 'users', 'process_types', 'subtasks', 'reorder_alerts', 'machined_parts'];
foreach ($tables as $table) {
$db->exec("DROP TABLE IF EXISTS `$table`");
}
// 1. Users (Admins and Workers)
$db->exec("CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
uuid VARCHAR(36) UNIQUE,
name VARCHAR(255) NOT NULL,
role ENUM('worker', 'admin') NOT NULL,
pin_hash VARCHAR(255) DEFAULT NULL,
assigned_processes JSON DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
// 2. Process Types (System defaults)
$db->exec("CREATE TABLE process_types (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
// 3. Jobs (Projects/Orders)
$db->exec("CREATE TABLE jobs (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
customer VARCHAR(255) DEFAULT NULL,
quantity INT DEFAULT 1,
due_date DATE DEFAULT NULL,
serial_number VARCHAR(100) UNIQUE,
priority ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal',
status ENUM('planned', 'in_progress', 'completed', 'archived', 'template') DEFAULT 'planned',
description TEXT DEFAULT NULL,
is_template BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME DEFAULT NULL
)");
// 4. Components (BOM Tree)
$db->exec("CREATE TABLE components (
id INT AUTO_INCREMENT PRIMARY KEY,
job_id INT NOT NULL,
parent_id INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(100) DEFAULT 'part',
status ENUM('pending', 'in_progress', 'completed') DEFAULT 'pending',
quantity INT DEFAULT 1,
thickness VARCHAR(50) DEFAULT NULL,
material VARCHAR(100) DEFAULT NULL,
notes TEXT DEFAULT NULL,
thumbnail_data LONGTEXT DEFAULT NULL,
blueprint_url VARCHAR(255) DEFAULT NULL,
order_index INT DEFAULT 0,
priority_weight INT DEFAULT 50,
assigned_to INT DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME DEFAULT NULL,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES components(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL
)");
// 5. Operations (Tasks within a Component)
$db->exec("CREATE TABLE operations (
id INT AUTO_INCREMENT PRIMARY KEY,
component_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
process_type VARCHAR(100) NOT NULL,
status ENUM('pending', 'in_progress', 'stalled', 'completed') DEFAULT 'pending',
order_index INT DEFAULT 0,
estimated_minutes INT DEFAULT NULL,
instructions TEXT DEFAULT NULL,
assigned_worker_id INT DEFAULT NULL,
quantity_made INT DEFAULT 0,
fulfilled_from_inventory BOOLEAN DEFAULT FALSE,
priority_tier ENUM('low', 'normal', 'high', 'urgent') DEFAULT 'normal',
blocked_reason TEXT DEFAULT NULL,
start_time DATETIME DEFAULT NULL,
completed_at DATETIME DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (component_id) REFERENCES components(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_worker_id) REFERENCES users(id) ON DELETE SET NULL
)");
// 6. Subtasks (Checklists for Operations/Components)
$db->exec("CREATE TABLE subtasks (
id INT AUTO_INCREMENT PRIMARY KEY,
component_id INT NOT NULL,
operation_id INT DEFAULT NULL,
name VARCHAR(255) NOT NULL,
status ENUM('pending', 'completed') DEFAULT 'pending',
FOREIGN KEY (component_id) REFERENCES components(id) ON DELETE CASCADE,
FOREIGN KEY (operation_id) REFERENCES operations(id) ON DELETE CASCADE
)");
// 7. Inventory (Materials & Consumables)
$db->exec("CREATE TABLE inventory (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category ENUM('material', 'consumable', 'hardware', 'machined_part') NOT NULL,
stock_level DECIMAL(10,2) DEFAULT 0,
reorder_level DECIMAL(10,2) DEFAULT 0,
unit VARCHAR(50) DEFAULT 'pcs',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
// 8. Reorder Alerts
$db->exec("CREATE TABLE reorder_alerts (
id INT AUTO_INCREMENT PRIMARY KEY,
inventory_id INT NOT NULL,
status ENUM('active', 'ordered', 'dismissed', 'fulfilled') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (inventory_id) REFERENCES inventory(id) ON DELETE CASCADE
)");
// 9. Time Study Events (Analytics)
$db->exec("CREATE TABLE time_study_events (
id INT AUTO_INCREMENT PRIMARY KEY,
operation_id INT NOT NULL,
user_id INT NOT NULL,
event_type ENUM('start', 'stalled', 'completed', 'resume') NOT NULL,
reason TEXT DEFAULT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (operation_id) REFERENCES operations(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)");
$db->exec("SET FOREIGN_KEY_CHECKS=1");
// SEED ONLY SYSTEM DATA (No user-added records)
// Default Admin User (Pin: 1234 hash)
$defaultPinHash = password_hash('1234', PASSWORD_DEFAULT);
$db->prepare("INSERT INTO users (name, role, pin_hash) VALUES (?, ?, ?)")
->execute(['System Admin', 'admin', $defaultPinHash]);
// Default Process Types
$processes = [
'Cutting', 'Welding', 'Bending', 'Assembly', 'Painting', 'Quality Control', 'Packaging', 'Machining'
];
$stmt = $db->prepare("INSERT INTO process_types (name) VALUES (?)");
foreach ($processes as $p) {
$stmt->execute([$p]);
}
echo "Full schema migrated successfully without user data. Admin PIN is 1234.\n";
} catch (Exception $e) {
$db->rollBack();
echo "Error: " . $e->getMessage() . "\n";
}

293
index.php
View File

@ -1,150 +1,207 @@
<?php
declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
session_start();
require 'db/config.php';
$db = db();
$phpVersion = PHP_VERSION;
$now = date('Y-m-d H:i:s');
if (isset($_SESSION['user_id'])) {
header('Location: dashboard.php');
exit;
}
$workers = $db->query("SELECT * FROM users WHERE role = 'worker'")->fetchAll();
$admins = $db->query("SELECT * FROM users WHERE role = 'admin'")->fetchAll();
$isSetup = isset($_GET['setup']) && isset($_SESSION['pending_setup_user_id']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title>
<?php
// Read project preview data from environment
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags -->
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
<title>M-TRACK | Login</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-color-start: #6a11cb;
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
--bg: #f8fafc;
--card-bg: #ffffff;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
display: flex;
justify-content: center;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2.5rem;
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.login-header {
text-align: center;
overflow: hidden;
margin-bottom: 2rem;
}
.login-header h1 {
font-weight: 700;
font-size: 1.5rem;
color: var(--primary);
letter-spacing: -0.025em;
margin-bottom: 0.25rem;
}
.login-header p {
font-size: 0.875rem;
color: #64748b;
}
.nav-tabs {
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.nav-link {
color: #64748b;
font-weight: 500;
border: none !important;
padding: 0.75rem 0;
margin-right: 1.5rem;
position: relative;
}
body::before {
.nav-link.active {
color: var(--accent) !important;
background: none !important;
}
.nav-link.active::after {
content: '';
position: absolute;
top: 0;
bottom: -2px;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
height: 2px;
background-color: var(--accent);
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
.form-label {
font-weight: 500;
font-size: 0.875rem;
color: var(--primary);
margin-bottom: 0.5rem;
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
.form-select, .form-control {
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
border-color: var(--border);
padding: 0.625rem;
font-size: 0.875rem;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
.form-select:focus, .form-control:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.btn-primary {
background-color: var(--primary);
border: none;
padding: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
border-radius: 4px;
width: 100%;
margin-top: 1rem;
}
.btn-primary:hover {
background-color: #0f172a;
}
.alert {
font-size: 0.8125rem;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
</style>
</head>
<body>
<main>
<div class="card">
<h1>Analyzing your requirements and generating your website…</h1>
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
<span class="sr-only">Loading…</span>
</div>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
<div class="login-card">
<div class="login-header">
<h1>M-TRACK</h1>
<p>Manufacturing Control System</p>
</div>
<?php if (isset($_GET['error'])): ?>
<div class="alert alert-danger">
<i class="bi bi-exclamation-circle me-2"></i> Invalid selection or PIN.
</div>
</main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC)
</footer>
<?php endif; ?>
<?php if ($isSetup): ?>
<form action="auth.php" method="POST">
<input type="hidden" name="action" value="setup_pin">
<div class="mb-3">
<label class="form-label">Create Admin PIN (4-6 digits)</label>
<input type="password" name="pin" class="form-control" required pattern="\d{4,6}" inputmode="numeric">
<div class="form-text mt-2">Initial setup for <?= htmlspecialchars($_SESSION['pending_setup_user_id']) ?></div>
</div>
<button type="submit" class="btn btn-primary">Set PIN & Login</button>
</form>
<?php else: ?>
<ul class="nav nav-tabs" id="loginTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="worker-tab" data-bs-toggle="tab" data-bs-target="#worker" type="button" role="tab">Worker</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-tab" data-bs-toggle="tab" data-bs-target="#admin" type="button" role="tab">Admin</button>
</li>
</ul>
<div class="tab-content" id="loginTabsContent">
<!-- Worker Tab -->
<div class="tab-pane fade show active" id="worker" role="tabpanel">
<form action="auth.php" method="POST">
<input type="hidden" name="action" value="login_worker">
<div class="mb-3">
<label class="form-label">Select Name</label>
<select name="user_id" class="form-select" required>
<option value="">Select...</option>
<?php foreach ($workers as $w): ?>
<option value="<?= $w['id'] ?>"><?= htmlspecialchars($w['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
<!-- Admin Tab -->
<div class="tab-pane fade" id="admin" role="tabpanel">
<form action="auth.php" method="POST">
<input type="hidden" name="action" value="login_admin">
<div class="mb-3">
<label class="form-label">Select Name</label>
<select name="user_id" class="form-select" required>
<option value="">Select...</option>
<?php foreach ($admins as $a): ?>
<option value="<?= $a['id'] ?>"><?= htmlspecialchars($a['name']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="mb-3">
<label class="form-label">Enter PIN</label>
<input type="password" name="pin" class="form-control" placeholder="••••" inputmode="numeric">
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
</html>

266
inventory.php Normal file
View File

@ -0,0 +1,266 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
header('Location: index.php');
exit;
}
$userId = $_SESSION['user_id'];
// Handle Inventory Update
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['update_stock'])) {
$itemId = $_POST['item_id'];
$qty = $_POST['quantity'];
$type = $_POST['type']; // in or out
try {
$db->beginTransaction();
// Record transaction
$stmt = $db->prepare("INSERT INTO inventory_transactions (inventory_id, type, quantity, user_id) VALUES (?, ?, ?, ?)");
$stmt->execute([$itemId, $type, $qty, $userId]);
// Update stock
$mod = ($type === 'in' ? '+' : '-');
$db->exec("UPDATE inventory SET stock_level = stock_level $mod $qty WHERE id = $itemId");
$db->commit();
header("Location: inventory.php?success=1");
exit;
} catch (Exception $e) {
$db->rollBack();
$error = "Error: " . $e->getMessage();
}
}
// Handle New Item
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['new_item'])) {
$name = $_POST['name'];
$cat = $_POST['category'];
$stock = $_POST['stock_level'];
$reorder = $_POST['reorder_level'];
$unit = $_POST['unit'];
$stmt = $db->prepare("INSERT INTO inventory (name, category, stock_level, reorder_level, unit) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$name, $cat, $stock, $reorder, $unit]);
header("Location: inventory.php?item_added=1");
exit;
}
$inventory = $db->query("SELECT * FROM inventory ORDER BY name ASC")->fetchAll();
$lowStock = array_filter($inventory, fn($i) => $i['stock_level'] <= $i['reorder_level']);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Inventory</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--bg: #f8fafc;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
--danger: #ef4444;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.sidebar {
width: var(--sidebar-width);
position: fixed;
top: 0;
bottom: 0;
left: 0;
background-color: var(--primary);
color: white;
z-index: 1000;
padding: 1.5rem 1rem;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2.5rem;
}
.card {
background: white;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
margin-bottom: 1.5rem;
}
.alert-low {
border-left: 4px solid var(--danger);
}
</style>
</head>
<body>
<div class="sidebar">
<h2>M-TRACK</h2>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link active" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<a class="nav-link" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Inventory Management</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newItemModal">
<i class="bi bi-plus-lg me-1"></i> New Item
</button>
</div>
<?php if (!empty($lowStock)): ?>
<div class="alert alert-danger d-flex align-items-center mb-4">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div><strong>Low Stock Warning:</strong> <?= count($lowStock) ?> items are below reorder level.</div>
</div>
<?php endif; ?>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Item Name</th>
<th>Category</th>
<th>Current Stock</th>
<th>Reorder Level</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<?php if (empty($inventory)): ?>
<tr><td colspan="5" class="p-5 text-center text-muted">No inventory items found.</td></tr>
<?php else: ?>
<?php foreach ($inventory as $item): ?>
<tr class="<?= $item['stock_level'] <= $item['reorder_level'] ? 'table-danger alert-low' : '' ?>">
<td>
<div class="fw-bold"><?= htmlspecialchars($item['name']) ?></div>
<div class="small text-muted"><?= $item['unit'] ?></div>
</td>
<td><span class="badge bg-light text-dark border"><?= strtoupper($item['category']) ?></span></td>
<td>
<span class="fw-bold <?= $item['stock_level'] <= $item['reorder_level'] ? 'text-danger' : '' ?>">
<?= $item['stock_level'] ?>
</span>
</td>
<td><?= $item['reorder_level'] ?></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick="setUpdateItem(<?= $item['id'] ?>, '<?= htmlspecialchars($item['name']) ?>')" data-bs-toggle="modal" data-bs-target="#updateModal">
Update Stock
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- New Item Modal -->
<div class="modal fade" id="newItemModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Inventory Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Item Name</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Category</label>
<select name="category" class="form-select">
<option value="material">Raw Material</option>
<option value="consumable">Consumable</option>
<option value="hardware">Hardware</option>
</select>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Initial Stock</label>
<input type="number" step="0.01" name="stock_level" class="form-control" value="0">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Reorder Level</label>
<input type="number" step="0.01" name="reorder_level" class="form-control" value="0">
</div>
</div>
<div class="mb-3">
<label class="form-label">Unit</label>
<input type="text" name="unit" class="form-control" placeholder="pcs, kg, meters, etc.">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="new_item" class="btn btn-primary">Add Item</button>
</div>
</form>
</div>
</div>
<!-- Update Stock Modal -->
<div class="modal fade" id="updateModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content">
<input type="hidden" name="item_id" id="update_item_id">
<div class="modal-header">
<h5 class="modal-title">Update Stock: <span id="update_item_name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Transaction Type</label>
<select name="type" class="form-select">
<option value="in">Stock In (Receive)</option>
<option value="out">Stock Out (Consume)</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Quantity</label>
<input type="number" step="0.01" name="quantity" class="form-control" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="update_stock" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function setUpdateItem(id, name) {
document.getElementById('update_item_id').value = id;
document.getElementById('update_item_name').innerText = name;
}
</script>
</body>
</html>

18
job_schema.json Normal file

File diff suppressed because one or more lines are too long

424
jobs.php Normal file
View File

@ -0,0 +1,424 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
header('Location: index.php');
exit;
}
$userName = $_SESSION['user_name'];
$userId = $_SESSION['user_id'];
// Handle Job Creation
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_job'])) {
$name = $_POST['name'];
$qty = $_POST['quantity'];
$due = $_POST['due_date'];
$sn = $_POST['serial_number'];
try {
$stmt = $db->prepare("INSERT INTO jobs (name, quantity, due_date, serial_number) VALUES (?, ?, ?, ?)");
$stmt->execute([$name, $qty, empty($due) ? null : $due, empty($sn) ? null : $sn]);
$jobId = $db->lastInsertId();
header("Location: jobs.php?id=$jobId");
exit;
} catch (Exception $e) {
$error = "Error: " . $e->getMessage();
}
}
// Handle Component Addition
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_component'])) {
$jobId = $_POST['job_id'];
$compName = $_POST['comp_name'];
$stmt = $db->prepare("INSERT INTO components (job_id, name) VALUES (?, ?)");
$stmt->execute([$jobId, $compName]);
header("Location: jobs.php?id=$jobId");
exit;
}
// Handle Operation Addition
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_op'])) {
$jobId = $_POST['job_id'];
$compId = $_POST['comp_id'];
$opName = $_POST['op_name'];
$proc = $_POST['process_type'];
$prio = $_POST['priority'];
$stmt = $db->prepare("INSERT INTO operations (component_id, name, process_type, priority) VALUES (?, ?, ?, ?)");
$stmt->execute([$compId, $opName, $proc, $prio]);
header("Location: jobs.php?id=$jobId");
exit;
}
$jobs = $db->query("SELECT * FROM jobs ORDER BY created_at DESC")->fetchAll();
$currentJobId = $_GET['id'] ?? null;
$currentJob = null;
$components = [];
if ($currentJobId) {
$stmt = $db->prepare("SELECT * FROM jobs WHERE id = ?");
$stmt->execute([$currentJobId]);
$currentJob = $stmt->fetch();
if ($currentJob) {
$stmt = $db->prepare("SELECT * FROM components WHERE job_id = ?");
$stmt->execute([$currentJobId]);
$components = $stmt->fetchAll();
foreach ($components as &$comp) {
$stmt = $db->prepare("SELECT * FROM operations WHERE component_id = ?");
$stmt->execute([$comp['id']]);
$comp['ops'] = $stmt->fetchAll();
}
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Job Management</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--bg: #f8fafc;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.sidebar {
width: var(--sidebar-width);
position: fixed;
top: 0;
bottom: 0;
left: 0;
background-color: var(--primary);
color: white;
z-index: 1000;
padding: 1.5rem 1rem;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2.5rem;
}
.sidebar h2 {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 2rem;
padding: 0 0.5rem;
letter-spacing: -0.025em;
}
.nav-pills .nav-link {
color: #94a3b8;
font-weight: 500;
font-size: 0.875rem;
padding: 0.625rem 0.75rem;
margin-bottom: 0.25rem;
border-radius: 4px;
}
.nav-pills .nav-link:hover {
color: white;
background-color: rgba(255,255,255,0.05);
}
.nav-pills .nav-link.active {
color: white;
background-color: var(--accent);
}
.card {
background: white;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
margin-bottom: 1.5rem;
}
.card-header {
background: white;
border-bottom: 1px solid var(--border);
padding: 1rem 1.25rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--primary);
}
.job-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.job-item:hover { background: #f1f5f9; }
.job-item.active { background: #eff6ff; border-left: 3px solid var(--accent); }
.component-box {
background: #f8fafc;
border: 1px solid var(--border);
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="sidebar">
<h2>M-TRACK</h2>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<a class="nav-link active" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<a class="nav-link" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<?php if (!empty($error)): ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<?= htmlspecialchars($error) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Job Management</h1>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newJobModal">
<i class="bi bi-plus-lg me-1"></i> New Job
</button>
<a href="bom_import.php" class="btn btn-outline-success ms-2">
<i class="bi bi-file-earmark-excel me-1"></i> Import BOM
</a>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">Existing Jobs</div>
<div class="card-body p-0" style="max-height: 70vh; overflow-y: auto;">
<?php if (empty($jobs)): ?>
<div class="p-4 text-center text-muted">No jobs found.</div>
<?php else: ?>
<?php foreach ($jobs as $job): ?>
<div class="job-item <?= $currentJobId == $job['id'] ? 'active' : '' ?>" onclick="location.href='jobs.php?id=<?= $job['id'] ?>'">
<div class="fw-bold"><?= htmlspecialchars($job['name']) ?></div>
<div class="small text-muted">SN: <?= htmlspecialchars($job['serial_number'] ?: 'N/A') ?> • <?= $job['status'] ?></div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="col-md-8">
<?php if ($currentJob): ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between">
Job Details: <?= htmlspecialchars($currentJob['name']) ?>
<span class="badge bg-light text-dark border"><?= strtoupper($currentJob['status']) ?></span>
</div>
<div class="card-body">
<div class="row small mb-3">
<div class="col-md-3"><strong>Serial:</strong> <?= htmlspecialchars($currentJob['serial_number']) ?></div>
<div class="col-md-3"><strong>Quantity:</strong> <?= $currentJob['quantity'] ?></div>
<div class="col-md-3"><strong>Due Date:</strong> <?= $currentJob['due_date'] ?></div>
<div class="col-md-3 text-end"><button class="btn btn-sm btn-outline-danger">Delete Job</button></div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0 fw-bold">Components & Operations</h6>
<div>
<a href="bom_import.php?job_id=<?= $currentJobId ?>" class="btn btn-sm btn-outline-success me-2">
<i class="bi bi-file-earmark-excel me-1"></i> Upload BOM
</a>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal" data-bs-target="#newCompModal">
<i class="bi bi-plus me-1"></i> Add Component
</button>
</div>
</div>
<?php foreach ($components as $comp): ?>
<div class="component-box">
<div class="d-flex justify-content-between mb-3">
<div class="d-flex align-items-center">
<?php if (!empty($comp["thumbnail_data"])): ?>
<img src="<?= $comp["thumbnail_data"] ?>" style="height:32px; width:32px; object-fit:cover; border-radius:4px; margin-right:10px; border:1px solid #dee2e6;">
<?php else: ?>
<div style="height:32px; width:32px; border-radius:4px; margin-right:10px; background:#e2e8f0; display:flex; align-items:center; justify-content:center; border:1px solid #cbd5e1;"><i class="bi bi-box" style="color:#94a3b8;"></i></div>
<?php endif; ?>
<div>
<h6 class="mb-0 fw-bold text-primary"><?= htmlspecialchars($comp["name"]) ?></h6>
<?php if(!empty($comp["material"]) || !empty($comp["thickness"])): ?>
<small class="text-muted"><?= htmlspecialchars($comp["material"]) ?> <?= htmlspecialchars($comp["thickness"]) ?></small>
<?php endif; ?>
</div>
</div>
<button class="btn btn-sm btn-link text-primary p-0" onclick="setCompId(<?= $comp['id'] ?>)" data-bs-toggle="modal" data-bs-target="#newOpModal">
<i class="bi bi-plus-circle me-1"></i> Add Op
</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered bg-white mb-0">
<thead class="table-light">
<tr class="small">
<th>Op Name</th>
<th>Process</th>
<th>Prio</th>
<th>Status</th>
</tr>
</thead>
<tbody class="small">
<?php if (empty($comp['ops'])): ?>
<tr><td colspan="4" class="text-center text-muted">No operations defined.</td></tr>
<?php else: ?>
<?php foreach ($comp['ops'] as $op): ?>
<tr>
<td><?= htmlspecialchars($op['name']) ?></td>
<td><?= strtoupper($op['process_type']) ?></td>
<td><?= $op['priority'] ?></td>
<td>
<span class="badge bg-<?= $op['status'] === 'completed' ? 'success' : ($op['status'] === 'in_progress' ? 'info' : 'secondary') ?>">
<?= $op['status'] ?>
</span>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<?php else: ?>
<div class="card py-5 text-center text-muted">
<i class="bi bi-arrow-left h1 mb-3"></i>
<h5>Select a job from the list or create a new one.</h5>
</div>
<?php endif; ?>
</div>
</div>
</div>
<!-- New Job Modal -->
<div class="modal fade" id="newJobModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Job</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Job Name</label>
<input type="text" name="name" class="form-control" required placeholder="e.g. Project X">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Quantity</label>
<input type="number" name="quantity" class="form-control" value="1">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Due Date</label>
<input type="date" name="due_date" class="form-control">
</div>
</div>
<div class="mb-3">
<label class="form-label">Serial Number</label>
<input type="text" name="serial_number" class="form-control" placeholder="SN-XXXX">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<a href="bom_import.php" class="btn btn-outline-success me-auto">Import BOM Instead</a>
<button type="submit" name="create_job" class="btn btn-primary">Create Job</button>
</div>
</form>
</div>
</div>
<!-- New Component Modal -->
<div class="modal fade" id="newCompModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content">
<input type="hidden" name="job_id" value="<?= $currentJobId ?>">
<div class="modal-header">
<h5 class="modal-title">Add Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Component Name</label>
<input type="text" name="comp_name" class="form-control" required placeholder="e.g. Chassis, Motor Mount">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="add_component" class="btn btn-primary">Add Component</button>
</div>
</form>
</div>
</div>
<!-- New Operation Modal -->
<div class="modal fade" id="newOpModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content">
<input type="hidden" name="job_id" value="<?= $currentJobId ?>">
<input type="hidden" name="comp_id" id="modal_comp_id">
<div class="modal-header">
<h5 class="modal-title">Add Operation</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Operation Name</label>
<input type="text" name="op_name" class="form-control" required placeholder="e.g. Laser Cut, MIG Weld">
</div>
<div class="mb-3">
<label class="form-label">Process Type</label>
<select name="process_type" class="form-select">
<option value="cutting">Cutting</option>
<option value="welding">Welding</option>
<option value="bending">Bending</option>
<option value="assembly">Assembly</option>
<option value="inspection">Inspection</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Priority (higher first)</label>
<input type="number" name="priority" class="form-control" value="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="add_op" class="btn btn-primary">Add Operation</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function setCompId(id) {
document.getElementById('modal_comp_id').value = id;
}
</script>
</body>
</html>

5
logout.php Normal file
View File

@ -0,0 +1,5 @@
<?php
session_start();
session_destroy();
header('Location: index.php');
exit;

21
operation_schema.json Normal file
View File

@ -0,0 +1,21 @@
{
"id": 1,
"componentId": 28,
"name": "sheetmetal cutting",
"processType": "sheetmetal cutting",
"orderIndex": 0,
"estimatedMinutes": null,
"instructions": null,
"status": "pending",
"assignedTo": null,
"completedAt": null,
"quantityMade": null,
"fulfilledFromInventory": false,
"priorityScore": null,
"priorityTier": "normal",
"readyAt": null,
"blockedReason": null,
"overriddenByUserId": null,
"overrideReason": null,
"overrideExpiresAt": null
}

1
operations.json Normal file

File diff suppressed because one or more lines are too long

16
replit_app_summary.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-fCLaIajI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aOXWrruY.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,808 @@
<!DOCTYPE html>
<html><head><meta charset='utf-8'><title>M-TRACK App Summary</title>
<style>
@media print { @page { margin: 0.75in; } }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a1a1a; max-width: 900px; margin: 0 auto; padding: 20px; }
h1 { font-size: 22pt; border-bottom: 3px solid #2563eb; padding-bottom: 8px; color: #111; }
h2 { font-size: 16pt; border-bottom: 1px solid #ddd; padding-bottom: 4px; margin-top: 28px; color: #1e40af; }
h3 { font-size: 13pt; margin-top: 20px; color: #333; }
code { background: #f3f4f6; padding: 1px 4px; border-radius: 3px; font-size: 10pt; }
pre { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 9pt; border: 1px solid #e5e7eb; }
pre code { background: none; padding: 0; }
ul, ol { padding-left: 24px; }
li { margin-bottom: 4px; }
hr { border: none; border-top: 2px solid #e5e7eb; margin: 24px 0; }
table { border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 10pt; }
th, td { border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; }
th { background: #f3f4f6; font-weight: 600; }
.print-btn { position: fixed; top: 20px; right: 20px; background: #2563eb; color: white; border: none; padding: 12px 24px; font-size: 14pt; border-radius: 8px; cursor: pointer; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,0.2); }
.print-btn:hover { background: #1d4ed8; }
@media print { .print-btn { display: none; } }
</style></head><body>
<button class="print-btn" onclick="window.print()">Save as PDF</button><h1>M-TRACK Manufacturing Control System - Complete Application Summary</h1>
<h2>Purpose</h2>
<p>M-TRACK is an internal manufacturing control system for a shop floor environment. It manages jobs, components (parts), operations (work steps), inventory, and worker task assignments. The app is designed for two user types: <strong>Admins</strong> (managers who create jobs, assign work, and monitor production) and <strong>Workers</strong> (shop floor operators who execute tasks).</p>
<p>The system is self-hosted on a Windows machine and accessed remotely via web browser (including mobile devices on the shop floor).</p>
<hr>
<h2>Deployment</h2>
<ul>
<li>Self-hosted on a Windows machine</li>
<li>Remote web access via browser (desktop and mobile)</li>
<li>PostgreSQL database for data persistence</li>
<li>Single server process serves both the API and the frontend</li>
<li>Environment variable <code>DATABASE_URL</code> for database connection</li>
<li>Environment variable <code>SESSION_SECRET</code> for session encryption</li>
</ul>
<hr>
<h2>Authentication &amp; Access Control</h2>
<h3>Two-Tier Login System</h3>
<ul>
<li><strong>Workers</strong>: Select their name from a dropdown on the landing page. No password required. Instant login for minimal friction on the shop floor.</li>
<li><strong>Admins</strong>: Select their name, then must enter a 4-6 digit numeric PIN.</li>
</ul>
<h3>PIN Management</h3>
<ul>
<li>PINs are stored as bcrypt-hashed values in the database</li>
<li>Admins can set, reset, and initialize PINs for other admin users</li>
<li>A &quot;Bootstrap&quot; mode exists for first-time setup: if no admin has a PIN configured, the landing page shows a setup dialog to create/initialize the first admin account</li>
</ul>
<h3>Sessions</h3>
<ul>
<li>Express-session based with 24-hour expiration</li>
<li>Session data stored server-side (MemoryStore for development, database-backed sessions table available for production)</li>
</ul>
<h3>Role-Based Access</h3>
<ul>
<li>Two roles: <code>admin</code> and <code>worker</code></li>
<li>Workers can only use the simplified login flow (no PIN)</li>
<li>Workers have an <code>assignedProcesses</code> array (e.g., [&quot;cutting&quot;, &quot;welding&quot;]) that filters which operations they see in their work queue</li>
<li>Admin-only features: User management, inventory deletion, PIN management, time study reports, process type configuration, viewing all workers&#39; queues</li>
</ul>
<hr>
<h2>Core Data Model</h2>
<h3>Jobs</h3>
<p>A job represents a manufacturing order (e.g., building a specific machine for a customer).</p>
<p>Fields:</p>
<ul>
<li>Product name (the thing being built)</li>
<li>Customer name</li>
<li>Order number</li>
<li>Machine serial number</li>
<li>Description and details (free text)</li>
<li>Status: planned, in_progress, completed</li>
<li>Quantity (how many units to build, default 1)</li>
<li>Priority: integer 1-5 (1 = highest)</li>
<li>Due date</li>
<li>Is template flag (templates are reusable job blueprints)</li>
<li>Archived at timestamp (soft delete)</li>
<li>BOM file name and BOM file data (stored for re-download)</li>
</ul>
<h3>Components</h3>
<p>A component is a part or subassembly within a job. Components form a tree hierarchy (parent-child relationships).</p>
<p>Fields:</p>
<ul>
<li>Name</li>
<li>Type: &quot;material&quot; (raw part) or &quot;assembly&quot; (subassembly containing other parts)</li>
<li>Processes: array of strings (e.g., [&quot;cutting&quot;, &quot;bending&quot;, &quot;welding&quot;])</li>
<li>Status: pending, in_progress, completed</li>
<li>Quantity (default 1)</li>
<li>Job ID (belongs to a job)</li>
<li>Parent ID (self-referencing for hierarchy: assemblies contain sub-components)</li>
<li>Assigned to (user ID)</li>
<li>Notes (free text)</li>
<li>Thickness (e.g., &#39;0.125&quot;&#39;, &#39;3mm&#39;)</li>
<li>Material (e.g., &quot;Mild Steel&quot;, &quot;Aluminum 6061&quot;)</li>
<li>Thumbnail data (base64 image extracted from BOM file)</li>
<li>Blueprint URL</li>
<li>Order index (display ordering)</li>
<li>Priority weight (1-100, default 50, higher = more important within the job)</li>
<li>Ready for assembly flag</li>
</ul>
<h3>Operations</h3>
<p>An operation is a specific work step on a component (e.g., &quot;Cut this part&quot;, &quot;Weld this part&quot;).</p>
<p>Fields:</p>
<ul>
<li>Component ID (belongs to a component)</li>
<li>Name (descriptive task name)</li>
<li>Process type (e.g., &quot;cutting&quot;, &quot;Sheetmetal cutting&quot;, &quot;bending&quot;, &quot;welding&quot;, &quot;machining&quot;, &quot;assembly&quot;)</li>
<li>Order index (sequence within the component)</li>
<li>Estimated minutes</li>
<li>Instructions (free text)</li>
<li>Status: pending, in_progress, stalled, completed, skipped</li>
<li>Assigned to (user ID)</li>
<li>Completed at timestamp</li>
<li>Quantity made (for machining operations that produce countable output)</li>
<li>Fulfilled from inventory flag (machining operation fulfilled from existing stock)</li>
<li>Priority score (calculated integer, lower = higher priority)</li>
<li>Priority tier: hot, urgent, normal, backlog (calculated from days until due date)</li>
<li>Ready at timestamp</li>
<li>Blocked reason (text explaining why the operation cannot proceed)</li>
<li>Override fields: overriddenByUserId, overrideReason, overrideExpiresAt (admin can bypass blocking)</li>
</ul>
<h3>Operation Dependencies</h3>
<p>Explicit cross-component blocking relationships.</p>
<p>Fields:</p>
<ul>
<li>Operation ID (the operation that is blocked)</li>
<li>Depends on operation ID (must complete before this operation can start)</li>
<li>Depends on component ID (entire component must complete before this operation can start)</li>
</ul>
<h3>Inventory Items</h3>
<p>Unified inventory system for all materials, manufactured parts, and consumables.</p>
<p>Fields:</p>
<ul>
<li>Item number (auto-assigned by category: consumables 1000+, materials 5000+, manufactured 6000+)</li>
<li>Name, description</li>
<li>Category: material, manufactured, consumable</li>
<li>SKU</li>
<li>Unit: pcs, kg, meters, liters, etc.</li>
<li>Quantity (current stock level)</li>
<li>Restock point (yellow warning threshold)</li>
<li>Reorder point (red critical threshold, triggers automatic alerts)</li>
<li>Reorder quantity (suggested order amount)</li>
<li>Storage location</li>
<li>Supplier name, contact, email</li>
<li>Unit cost</li>
<li>Minimum order quantity</li>
<li>Lead time in days</li>
<li>Last restock date</li>
</ul>
<h3>Inventory Transactions</h3>
<p>Audit trail for every stock change.</p>
<p>Fields:</p>
<ul>
<li>Inventory item ID</li>
<li>Transaction type: restock, consume, adjust, manufacture</li>
<li>Quantity change</li>
<li>Previous quantity and new quantity</li>
<li>Note (reason for change)</li>
<li>User ID (who performed it)</li>
</ul>
<h3>Reorder Alerts</h3>
<p>Tracks inventory replenishment requests.</p>
<p>Fields:</p>
<ul>
<li>Inventory item ID</li>
<li>Status: pending, approved, ordered, received, dismissed</li>
<li>Triggered by: scan (manual QR scan) or auto (hit reorder point)</li>
<li>Scanned by user ID</li>
<li>Approved by user ID, approved at timestamp</li>
<li>Order quantity</li>
<li>Supplier email sent flag and timestamp</li>
<li>Note</li>
</ul>
<h3>Time Study Events</h3>
<p>Performance tracking log.</p>
<p>Fields:</p>
<ul>
<li>Operation ID, component ID, job ID</li>
<li>Worker ID (who performed the work)</li>
<li>Recorded by (who logged the event)</li>
<li>Event type: start, stalled, completed</li>
<li>Note</li>
<li>Occurred at timestamp</li>
</ul>
<h3>Users</h3>
<p>Fields:</p>
<ul>
<li>ID (UUID)</li>
<li>Username (unique, required)</li>
<li>Email, first name, last name, profile image URL (optional)</li>
<li>Role: admin or worker</li>
<li>Assigned processes: array of strings (e.g., [&quot;cutting&quot;, &quot;bending&quot;])</li>
<li>Has completed tutorial: boolean</li>
<li>PIN hash (bcrypt, for admin login)</li>
</ul>
<h3>Process Types</h3>
<p>Configurable list of available manufacturing processes (admin-managed).</p>
<p>Fields:</p>
<ul>
<li>Name (unique, e.g., &quot;cutting&quot;, &quot;Sheetmetal cutting&quot;, &quot;bending&quot;, &quot;welding&quot;, &quot;machining&quot;, &quot;assembly&quot;)</li>
<li>Order index (display ordering)</li>
</ul>
<h3>Other Tables</h3>
<ul>
<li><strong>Subtasks</strong>: Granular checklist items within a component (id, name, status, componentId)</li>
<li><strong>Consumables</strong>: Legacy table for small shop items</li>
<li><strong>Kanban Cards</strong>: Legacy cards for consumable tracking</li>
<li><strong>Machined Parts Inventory</strong>: Tracks stock of machined parts (partName, quantity)</li>
<li><strong>Sessions</strong>: Server-side session storage (sid, sess JSON, expire timestamp)</li>
<li><strong>Operation Assignment Log</strong>: Audit trail for operation assignments (operationId, userId, action, performedByUserId, reason)</li>
</ul>
<hr>
<h2>Key Features</h2>
<h3>1. Job Management</h3>
<p><strong>Creating Jobs:</strong></p>
<ul>
<li>Manually create jobs with product name, customer, serial number, priority, due date, description</li>
<li>Import from BOM (Bill of Materials) Excel files</li>
<li>Instantiate from saved templates</li>
</ul>
<p><strong>Job Templates:</strong></p>
<ul>
<li>Any job can be saved as a template</li>
<li>Templates preserve the full component tree and operation structure</li>
<li>Instantiating a template creates a new active job with all components and operations copied</li>
</ul>
<p><strong>Job Lifecycle:</strong></p>
<ul>
<li>Status progression: planned -&gt; in_progress -&gt; completed</li>
<li>Jobs can be archived (soft delete) and unarchived</li>
<li>Archived jobs are hidden from main views but preserved in the database</li>
</ul>
<p><strong>Job Priority:</strong></p>
<ul>
<li>Integer 1-5 (1 = Critical/highest, 5 = Lowest)</li>
<li>Priority drives the ordering of worker task queues</li>
<li>Combined with due date for automatic priority score calculation</li>
</ul>
<h3>2. BOM Import</h3>
<p><strong>File Format:</strong></p>
<ul>
<li>Accepts Excel files (.xlsx, .xls) and CSV</li>
<li>Uses the <code>xlsx</code> library to read data and <code>exceljs</code> to extract embedded thumbnail images</li>
</ul>
<p><strong>Column Detection:</strong></p>
<ul>
<li>Auto-detects columns by header name using exact and partial matching</li>
<li>Looks for: Item Number, Part Name, Quantity, Material, Thickness</li>
</ul>
<p><strong>Inventor File Name Parsing:</strong></p>
<ul>
<li>Designed to parse Autodesk Inventor file naming conventions</li>
<li>Format: <code>Model_MajorComponent_PartName.ipt</code> or <code>Model_MajorComponent_PartName.iam</code></li>
<li>File extensions (.iam, .ipt) are stripped</li>
<li>CamelCase is split into separate words (e.g., &quot;UpperCylinder&quot; -&gt; &quot;Upper Cylinder&quot;)</li>
<li>Underscores become spaces</li>
<li>Hyphens are preserved with spaces</li>
</ul>
<p><strong>Major Component Codes:</strong></p>
<ul>
<li>Recognizes codes like RB (Roaster Body), CC (Chaff Can), CT (Cooling Tray)</li>
<li>These codes determine which subassembly a part belongs to</li>
</ul>
<p><strong>Two-Pass Component Creation:</strong></p>
<ol>
<li>First pass: Creates subassemblies (items with whole-number IDs like &quot;1&quot;, &quot;2&quot;) as type &quot;assembly&quot;</li>
<li>Second pass: Creates parts (items with decimal IDs like &quot;1.1&quot;, &quot;1.2&quot;) as type &quot;material&quot;, assigned to their parent subassembly</li>
</ol>
<p><strong>Filtering Logic:</strong></p>
<ul>
<li>Parts must have a valid major component prefix (RB, CC, CT) in their file name</li>
<li>Parts must have material containing &quot;MCR&quot; to be imported</li>
<li>This filters out hardware, fasteners, and purchased parts</li>
</ul>
<p><strong>BOM Update:</strong></p>
<ul>
<li>Existing jobs can have their BOM re-imported</li>
<li>The update process preserves existing operations and work progress</li>
<li>New components are added, removed components are deleted, existing ones are updated</li>
<li>Returns counts of updated, added, and removed components</li>
</ul>
<p><strong>Thumbnail Extraction:</strong></p>
<ul>
<li>Embedded images in Excel files are extracted as base64 data</li>
<li>Matched to components by row position</li>
<li>Displayed as component thumbnails in the UI</li>
</ul>
<h3>3. Component Tree &amp; Hierarchy</h3>
<ul>
<li>Components form a tree: Jobs contain top-level assemblies, which contain sub-components</li>
<li>Parent-child relationships via parentId field</li>
<li>Components can be moved between parents via drag or API</li>
<li>Order index controls display ordering within a parent</li>
<li>Assembly components act as containers; their operations are blocked until all child components are completed</li>
</ul>
<h3>4. Operations &amp; Dependencies</h3>
<p><strong>Operation Creation:</strong></p>
<ul>
<li>Operations are created on individual components</li>
<li>Each operation has a process type (cutting, bending, welding, machining, assembly, etc.)</li>
<li>Operations have an order index that determines their sequence within a component</li>
</ul>
<p><strong>Bulk Operations:</strong></p>
<ul>
<li>Admin can assign operations in bulk across multiple components</li>
<li>Select multiple components and multiple process types to create operations for all combinations</li>
<li>Bulk delete also supported</li>
</ul>
<p><strong>Operation Reordering:</strong></p>
<ul>
<li>Operations within a component can be reordered by dragging or via API</li>
<li>Order determines sequential dependency (operation at index 0 must complete before index 1 can start)</li>
</ul>
<p><strong>Dependency System (Three Layers):</strong></p>
<ol>
<li><strong>Sequential</strong>: Within a component, operations must complete in order index sequence</li>
<li><strong>Hierarchical</strong>: Assembly operations are blocked until all child components are completed</li>
<li><strong>Explicit</strong>: Cross-component dependencies defined in the operation_dependencies table</li>
</ol>
<p><strong>Blocking Logic:</strong></p>
<ul>
<li>An operation is &quot;blocked&quot; if any of its dependencies (sequential, hierarchical, or explicit) are not yet completed</li>
<li>Blocked operations appear at the bottom of worker queues with a reason explaining the block</li>
<li>Admins can override blocks by providing an override reason (with optional expiration)</li>
</ul>
<p><strong>Priority Calculation:</strong></p>
<ul>
<li>Priority score = basePriority * (100 - componentWeight) / 100</li>
<li>basePriority = daysUntilDue * 100</li>
<li>Lower score = higher priority</li>
<li>Priority tiers assigned by days until due: hot (&lt;=2 days), urgent (&lt;=5 days), normal (&lt;=14 days), backlog (&gt;14 days)</li>
<li>Recalculation can be triggered manually via API</li>
</ul>
<h3>5. Batch Task Grouping (Cutting Operations Only)</h3>
<p><strong>What Gets Batched:</strong></p>
<ul>
<li>ONLY operations where the process type contains &quot;cutting&quot; or &quot;cut&quot; (case-insensitive), or equals &quot;material&quot;</li>
<li>Examples that batch: &quot;cutting&quot;, &quot;Sheetmetal cutting&quot;, &quot;Steel Tube cutting&quot;, &quot;material&quot;</li>
<li>Examples that do NOT batch: &quot;bending&quot;, &quot;welding&quot;, &quot;machining&quot;, &quot;assembly&quot;</li>
</ul>
<p><strong>Grouping Criteria:</strong></p>
<ul>
<li>Operations are grouped by: Process Type + Material + Thickness + Job ID</li>
<li>All operations sharing these attributes become a single batch that workers can start/complete together</li>
</ul>
<p><strong>Why Batching Exists:</strong></p>
<ul>
<li>In manufacturing, cutting is often done on a single machine for all parts of the same material and thickness</li>
<li>A worker sets up the machine once and cuts all parts, rather than switching materials repeatedly</li>
</ul>
<p><strong>Batch Display:</strong></p>
<ul>
<li>Batches show a count of operations (e.g., &quot;12 parts&quot;)</li>
<li>Individual parts within a batch are listed for reference</li>
</ul>
<h3>6. Task Naming Format (Two-Line Display)</h3>
<p>All work items (batches and individual operations) are displayed in a consistent two-line format:</p>
<p><strong>Line 1 (Title):</strong> <code>[Process] [Product] [Main Assembly] [Sub-Assembly]</code></p>
<ul>
<li>Process: &quot;Cut&quot; for cutting/material operations, otherwise the capitalized process name</li>
<li>Product: The job&#39;s product name</li>
<li>Main Assembly: The top-level assembly name (if the component has a parent hierarchy)</li>
<li>Sub-Assembly: The intermediate assembly name (if applicable)</li>
</ul>
<p><strong>Line 2 (Subtitle):</strong> <code>[Thickness] [Material] - [Customer] - [Serial Number]</code></p>
<ul>
<li>Parts are separated by &quot; - &quot; dash separators</li>
<li>Empty parts are omitted</li>
<li>Thickness: Displayed exactly as it comes from the BOM, with &quot; in&quot; suffix converted to double-quote (&quot;) for inches</li>
</ul>
<h3>7. Worker Queue / &quot;Next Work&quot; View</h3>
<p><strong>Purpose:</strong></p>
<ul>
<li>Shows each worker a prioritized list of tasks they should work on next</li>
<li>Filtered to only show operations matching the worker&#39;s assigned processes</li>
</ul>
<p><strong>Three Sections:</strong></p>
<ol>
<li><strong>In Progress</strong>: Tasks the worker has already started</li>
<li><strong>Stalled</strong>: Tasks that are paused with a reason</li>
<li><strong>Pending</strong>: Tasks available to start, ordered by priority</li>
</ol>
<p><strong>Mixed Content:</strong></p>
<ul>
<li>Cutting batches (grouped by material + thickness + job) appear as single items</li>
<li>All other operations (welding, bending, machining, assembly) appear as individual items</li>
<li>Both types are interleaved in a single prioritized list</li>
</ul>
<p><strong>Priority Ordering:</strong></p>
<ol>
<li>In-progress items appear at the top</li>
<li>Higher job priority (lower number) comes first</li>
<li>Earlier due dates come first (no due date goes to bottom)</li>
<li>Blocked items are pushed to the bottom with a reason displayed</li>
</ol>
<p><strong>Worker Actions:</strong></p>
<ul>
<li><strong>Start</strong>: Moves operation(s) from pending to in_progress, assigns to the current worker, records a time study &quot;start&quot; event</li>
<li><strong>Stalled</strong>: Pauses work; worker MUST provide a reason (e.g., &quot;missing hardware&quot;, &quot;machine down&quot;). Records a time study &quot;stalled&quot; event</li>
<li><strong>Done (Complete)</strong>: Marks operation(s) as completed with timestamp. Records a time study &quot;completed&quot; event. For batches, all operations in the batch are completed simultaneously</li>
<li><strong>Resume</strong>: Moves stalled tasks back to in_progress</li>
</ul>
<p><strong>Admin View:</strong></p>
<ul>
<li>Admins can select any worker from a dropdown to view their queue</li>
<li>Admins can reassign batches to different workers</li>
</ul>
<h3>8. Shop Floor View</h3>
<p><strong>Two Modes:</strong></p>
<p><strong>Batch View (Task-Based):</strong></p>
<ul>
<li>Shows all cutting batches grouped by material + thickness</li>
<li>Filter by process type</li>
<li>Mark batches as complete or stalled</li>
<li>Shows in-progress work at the top</li>
</ul>
<p><strong>Kanban Board (Component-Based):</strong></p>
<ul>
<li>Three columns: Pending, In Progress, Completed</li>
<li>Drag-and-drop components between columns to update status</li>
<li>Uses @hello-pangea/dnd library</li>
<li>Filter by process type</li>
<li>Expand components to view and manage subtasks</li>
</ul>
<h3>9. Dashboard</h3>
<p><strong>Stat Cards (Clickable):</strong></p>
<ul>
<li>Active Jobs: Count of in_progress jobs (click to see list)</li>
<li>Completed Units: Count of completed jobs (click to see list)</li>
<li>Low Stock Alert: Count of consumables below reorder level (click to see list)</li>
<li>Total Jobs: Total job count (click to see list)</li>
</ul>
<p><strong>Charts:</strong></p>
<ul>
<li>Job Status Bar Chart: Planned vs In Progress vs Completed job counts (using Recharts)</li>
<li>Recent Jobs List: 5 most recent jobs with status badges</li>
</ul>
<p><strong>Work Management:</strong></p>
<ul>
<li>Workers see their assigned &quot;Next Work&quot; items</li>
<li>Admins see a global view of all in-progress and stalled work</li>
<li>Admins can select specific workers to view their assigned work</li>
<li>Start/Stall/Done actions available directly from the dashboard</li>
<li>In-progress work section highlighted at the top</li>
</ul>
<p><strong>Job Detail Dialog:</strong></p>
<ul>
<li>Click a job to see metadata (quantity, due date, serial number)</li>
<li>Breakdown of incomplete vs completed tasks</li>
</ul>
<h3>10. Inventory Management</h3>
<p><strong>Three Categories (Tabs):</strong></p>
<ol>
<li><strong>Materials</strong>: Raw materials (steel, aluminum, etc.)</li>
<li><strong>Manufactured Parts</strong>: Parts produced in-house (machined parts with stock tracking)</li>
<li><strong>Consumables</strong>: Shop supplies (welding wire, grinding discs, etc.)</li>
</ol>
<p><strong>Item Number Auto-Assignment:</strong></p>
<ul>
<li>Consumables: 1000+</li>
<li>Materials: 5000+</li>
<li>Manufactured parts: 6000+</li>
</ul>
<p><strong>CRUD Operations:</strong></p>
<ul>
<li>Add, edit, delete inventory items</li>
<li>Quantity adjustment with transaction type (restock, consume, adjust, manufacture)</li>
<li>Every adjustment creates a transaction log entry</li>
</ul>
<p><strong>Low-Stock Alerting:</strong></p>
<ul>
<li>Two thresholds per item: restock point (warning) and reorder point (critical)</li>
<li>When quantity falls to or below reorder point, an automatic reorder alert is created</li>
<li>Manual alerts via QR code scanning</li>
</ul>
<p><strong>Reorder Alert Workflow:</strong></p>
<ul>
<li>Status progression: pending -&gt; approved -&gt; ordered -&gt; received (or dismissed)</li>
<li>Admins approve/dismiss alerts</li>
<li>System can generate pre-filled supplier email for ordering</li>
<li>Notification badge in sidebar shows pending alert count</li>
</ul>
<p><strong>QR Code / Kanban Cards:</strong></p>
<ul>
<li>Generate printable kanban cards with QR codes for physical inventory items</li>
<li>Workers scan QR codes to trigger reorder alerts</li>
<li>Camera-based QR scanning (requires HTTPS) or manual item ID entry</li>
</ul>
<p><strong>Machining Integration:</strong></p>
<ul>
<li>When a machining operation is completed, workers enter &quot;quantity made&quot;</li>
<li>If quantity exceeds what the job needs, excess is automatically added to machined parts inventory</li>
<li>Before starting a machining operation, the system checks if the part already exists in inventory</li>
<li>Workers can &quot;fulfill from inventory&quot; to skip machining and use existing stock</li>
</ul>
<h3>11. Time Study / Performance Reports</h3>
<p><strong>Event Tracking:</strong></p>
<ul>
<li>Every Start, Stalled, and Completed action on an operation creates a time study event</li>
<li>Events record: operation, component, job, worker, event type, timestamp, optional note</li>
</ul>
<p><strong>Reports (Admin Only):</strong></p>
<ul>
<li>Filter by worker and/or job</li>
<li>Shows total active time (time between start and completed events)</li>
<li>Shows total stalled time (time between stalled and resumed events)</li>
<li>Event log with timestamps for detailed analysis</li>
</ul>
<h3>12. Tutorial / Onboarding System</h3>
<p><strong>Automatic Trigger:</strong></p>
<ul>
<li>Tutorial appears automatically for any user who hasn&#39;t completed it (hasCompletedTutorial = false)</li>
</ul>
<p><strong>8 Steps:</strong></p>
<ol>
<li>Welcome to M-TRACK</li>
<li>Dashboard overview</li>
<li>Jobs Management</li>
<li>Shop Floor</li>
<li>Inventory</li>
<li>Your Work Queue</li>
<li>Settings</li>
<li>Completion (&quot;You&#39;re Ready!&quot;)</li>
</ol>
<p><strong>Controls:</strong></p>
<ul>
<li>Navigate with Next/Previous buttons or Arrow keys</li>
<li>Skip with Escape key or Skip button</li>
<li>Restart from Settings page</li>
<li>Completion status saved to database so it doesn&#39;t reappear</li>
</ul>
<p><strong>Implementation:</strong></p>
<ul>
<li>React Context (TutorialContext) manages state</li>
<li>Overlay component with Framer Motion animations</li>
<li>Persisted via API calls to mark completion/reset</li>
</ul>
<h3>13. Settings Page</h3>
<p><strong>For All Users:</strong></p>
<ul>
<li>View account info</li>
<li>Restart tutorial</li>
<li>View system architecture diagram</li>
</ul>
<p><strong>For Admins:</strong></p>
<ul>
<li>User Management: Create users, edit roles/usernames, assign work processes, manage PINs</li>
<li>Process Type Configuration: Add or remove manufacturing process categories</li>
</ul>
<h3>14. Architecture Diagram</h3>
<ul>
<li>Interactive Mermaid.js diagrams</li>
<li>Two views: System Architecture and User Workflow</li>
<li>Zoom in/out capability</li>
<li>Download as SVG</li>
<li>Shows how components relate (frontend, backend, database, user flows)</li>
</ul>
<hr>
<h2>UI Navigation</h2>
<h3>Sidebar</h3>
<ul>
<li>Dashboard</li>
<li>Jobs</li>
<li>Shop Floor</li>
<li>Worker Queue</li>
<li>Inventory</li>
<li>Scan (QR scanning)</li>
<li>Reorder Alerts (with notification badge showing pending count)</li>
<li>Time Study (admin only)</li>
<li>Settings</li>
<li>Architecture Diagram</li>
<li>Logout</li>
</ul>
<h3>Responsive Design</h3>
<ul>
<li>Works on desktop and mobile browsers</li>
<li>Sidebar collapses on mobile</li>
<li>Touch-friendly interactions for shop floor use</li>
</ul>
<hr>
<h2>Key Business Rules</h2>
<ol>
<li><p><strong>Cutting operations batch together</strong> by material + thickness + job. All other processes are individual operations.</p>
</li>
<li><p><strong>Operation blocking</strong> prevents workers from starting work out of sequence. Sequential order within a component, assembly hierarchy, and explicit cross-component dependencies all contribute to blocking.</p>
</li>
<li><p><strong>BOM updates preserve existing work.</strong> Re-importing a BOM updates metadata but does not delete operations that have already been started or completed.</p>
</li>
<li><p><strong>Machined parts can be fulfilled from inventory</strong> instead of re-machining, saving production time.</p>
</li>
<li><p><strong>Reorder alerts fire automatically</strong> when stock hits the reorder point, but can also be triggered manually via QR scan.</p>
</li>
<li><p><strong>Priority scoring is automatic</strong> based on due date and component weight, but jobs also have a manual priority override (1-5).</p>
</li>
<li><p><strong>Workers only see operations matching their assigned processes.</strong> A worker assigned to &quot;cutting&quot; and &quot;bending&quot; won&#39;t see welding tasks.</p>
</li>
<li><p><strong>Admins can override operation blocking</strong> with a reason and optional expiration time.</p>
</li>
<li><p><strong>Time study events are created automatically</strong> whenever a worker starts, stalls, or completes an operation.</p>
</li>
<li><p><strong>Process types are configurable</strong> - admins can add new process types without code changes.</p>
</li>
</ol>
<hr>
<h2>API Overview</h2>
<h3>Authentication</h3>
<ul>
<li><code>POST /api/login</code> - Worker login (select user, no PIN)</li>
<li><code>POST /api/auth/login-with-pin</code> - Admin login with PIN</li>
<li><code>GET /api/auth/user</code> - Get current session user</li>
<li><code>POST /api/logout</code> - End session</li>
<li><code>GET /api/auth/bootstrap-status</code> - Check if first-time setup needed</li>
<li><code>POST /api/auth/bootstrap-initialize</code> - Create/initialize first admin</li>
<li><code>GET /api/auth/check-pin/:userId</code> - Check if user requires PIN</li>
<li><code>POST /api/auth/set-pin</code> - Set own PIN</li>
<li><code>POST /api/auth/reset-pin/:userId</code> - Admin reset another user&#39;s PIN</li>
<li><code>POST /api/auth/initialize-pin/:userId</code> - Admin set PIN for another user</li>
<li><code>POST /api/auth/complete-tutorial</code> - Mark tutorial complete</li>
<li><code>POST /api/auth/reset-tutorial</code> - Reset tutorial for current user</li>
</ul>
<h3>Users</h3>
<ul>
<li><code>GET /api/users</code> - List all users</li>
<li><code>POST /api/users</code> - Create user (username, role, assignedProcesses)</li>
<li><code>PATCH /api/users/:id</code> - Update user</li>
</ul>
<h3>Jobs</h3>
<ul>
<li><code>GET /api/jobs</code> - List jobs (query: isTemplate=true/false)</li>
<li><code>GET /api/jobs/:id</code> - Get single job</li>
<li><code>POST /api/jobs</code> - Create job</li>
<li><code>PATCH /api/jobs/:id</code> - Update job</li>
<li><code>DELETE /api/jobs/:id</code> - Delete job</li>
<li><code>POST /api/jobs/:id/archive</code> - Soft delete</li>
<li><code>POST /api/jobs/:id/unarchive</code> - Restore archived job</li>
<li><code>GET /api/jobs/archived</code> - List archived jobs</li>
<li><code>POST /api/jobs/:id/instantiate</code> - Create job from template</li>
<li><code>POST /api/jobs/:id/save-template</code> - Save job as template</li>
<li><code>POST /api/jobs/import-bom</code> - Import BOM file (multipart/form-data)</li>
<li><code>GET /api/jobs/:id/bom</code> - Download original BOM file</li>
<li><code>POST /api/jobs/:id/bom/update</code> - Re-import BOM preserving existing work</li>
</ul>
<h3>Components</h3>
<ul>
<li><code>GET /api/components</code> - List (query: jobId, assignedTo)</li>
<li><code>POST /api/components</code> - Create component</li>
<li><code>PATCH /api/components/:id</code> - Update component</li>
<li><code>DELETE /api/components/:id</code> - Delete component</li>
<li><code>POST /api/components/move</code> - Move component to new parent</li>
</ul>
<h3>Operations</h3>
<ul>
<li><code>GET /api/operations</code> - List (query: componentId)</li>
<li><code>GET /api/operations/:id</code> - Get single operation</li>
<li><code>POST /api/operations</code> - Create operation</li>
<li><code>PATCH /api/operations/:id</code> - Update operation</li>
<li><code>DELETE /api/operations/:id</code> - Delete operation</li>
<li><code>POST /api/operations/reorder</code> - Reorder operations within a component</li>
<li><code>POST /api/operations/bulk</code> - Bulk create operations across components</li>
<li><code>DELETE /api/operations/bulk</code> - Bulk delete operations</li>
</ul>
<h3>Batch Tasks &amp; Worker Queue</h3>
<ul>
<li><code>GET /api/batch-tasks</code> - Get batch groups (query: processType, jobId, status)</li>
<li><code>POST /api/batch-tasks/start</code> - Start a batch (operationIds + userId)</li>
<li><code>POST /api/batch-tasks/complete</code> - Complete a batch</li>
<li><code>POST /api/batch-tasks/stall</code> - Stall a batch (with reason)</li>
<li><code>POST /api/batch-tasks/dismiss-stall</code> - Resume stalled batch</li>
<li><code>GET /api/batch-tasks/worker/:userId</code> - Get batches for a worker</li>
<li><code>GET /api/work/next/:userId</code> - Get unified next work (batches + individual ops)</li>
<li><code>GET /api/work/assigned</code> - Get current user&#39;s assigned work</li>
<li><code>GET /api/work/all-workers</code> - Get all workers&#39; current work (admin)</li>
</ul>
<h3>Inventory</h3>
<ul>
<li><code>GET /api/inventory</code> - List items (query: category)</li>
<li><code>POST /api/inventory</code> - Create item</li>
<li><code>PATCH /api/inventory/:id</code> - Update item</li>
<li><code>DELETE /api/inventory/:id</code> - Delete item (admin only)</li>
<li><code>POST /api/inventory/:id/adjust</code> - Adjust quantity with transaction log</li>
<li><code>GET /api/inventory/:id/transactions</code> - Get transaction history</li>
<li><code>GET /api/inventory-transactions</code> - Get all transactions</li>
</ul>
<h3>Machined Parts</h3>
<ul>
<li><code>GET /api/machined-parts</code> - List machined parts inventory</li>
<li><code>POST /api/operations/:id/complete-machining</code> - Complete machining with quantity tracking</li>
<li><code>POST /api/operations/:id/fulfill-from-inventory</code> - Use existing stock instead of machining</li>
<li><code>GET /api/machined-parts/check/:partName</code> - Check if part exists in inventory</li>
</ul>
<h3>Consumables (Legacy)</h3>
<ul>
<li><code>GET /api/consumables</code> - List consumables</li>
<li><code>POST /api/consumables</code> - Create consumable</li>
<li><code>PATCH /api/consumables/:id</code> - Update consumable</li>
</ul>
<h3>Alerts &amp; Scanning</h3>
<ul>
<li><code>GET /api/reorder-alerts</code> - List alerts (query: status)</li>
<li><code>GET /api/reorder-alerts/count</code> - Get pending alert count</li>
<li><code>POST /api/reorder-alerts</code> - Create manual alert</li>
<li><code>PATCH /api/reorder-alerts/:id</code> - Update alert status</li>
<li><code>POST /api/scan/:itemId</code> - Scan item to trigger reorder alert</li>
</ul>
<h3>Reports &amp; Config</h3>
<ul>
<li><code>GET /api/time-study/report</code> - Get time study data (query: workerId, jobId, componentId)</li>
<li><code>POST /api/time-study</code> - Record time study event</li>
<li><code>GET /api/process-types</code> - List configured process types</li>
<li><code>POST /api/process-types</code> - Add process type</li>
<li><code>DELETE /api/process-types/:id</code> - Remove process type</li>
<li><code>POST /api/recalculate-priorities</code> - Recalculate all operation priorities</li>
</ul>
<h3>Subtasks</h3>
<ul>
<li><code>GET /api/subtasks</code> - List (query: componentId)</li>
<li><code>POST /api/subtasks</code> - Create subtask</li>
<li><code>PATCH /api/subtasks/:id</code> - Update subtask</li>
<li><code>DELETE /api/subtasks/:id</code> - Delete subtask</li>
</ul>
<hr>
<h2>Architecture Diagram (Text)</h2>
<pre><code>+------------------+ HTTP/REST +------------------+ SQL +------------------+
| | &lt;===============&gt; | | &lt;==========&gt; | |
| React SPA | | Express.js | | PostgreSQL |
| (Frontend) | | (Backend) | | (Database) |
| | | | | |
| - Dashboard | | - REST API | | - Jobs |
| - Jobs | | - Session Auth | | - Components |
| - Shop Floor | | - BOM Parser | | - Operations |
| - Worker Queue | | - Batch Logic | | - Dependencies |
| - Inventory | | - Priority Calc | | - Inventory |
| - Settings | | - File Upload | | - Time Study |
| - Time Study | | | | - Users |
| - Scan (QR) | | | | - Sessions |
| | | | | |
+------------------+ +------------------+ +------------------+
| |
| Single server process |
| serves both frontend |
| static files and API |
+--------------------------------------+
</code></pre>
<h2>User Flow Diagram (Text)</h2>
<pre><code> +------------------+
| Landing Page |
| (User Select) |
+--------+---------+
|
+--------+---------+
| Worker or Admin? |
+--------+---------+
|
+--------------+--------------+
| |
+--------v---------+ +---------v--------+
| Worker Login | | Admin PIN Entry |
| (No PIN needed) | | (4-6 digits) |
+--------+----------+ +---------+--------+
| |
+-------------+---------------+
|
+--------v---------+
| Dashboard |
| (Role-specific) |
+--------+---------+
|
+-------------------+-------------------+
| | | |
+----v----+ +---v----+ +----v-----+ +------v------+
| Jobs | | Shop | | Worker | | Inventory |
| | | Floor | | Queue | | |
+---------+ +--------+ +----+-----+ +------+------+
| |
+------v------+ +-----v-------+
| Start/Stall | | Reorder |
| Done Actions| | Alerts |
+-------------+ +-------------+
</code></pre>
<hr>
<h2>Important Implementation Notes for Recreation</h2>
<ol>
<li><p><strong>The &quot;isCuttingProcess&quot; helper</strong> must use case-insensitive matching. Check if processType.toLowerCase() includes &quot;cutting&quot; or &quot;cut&quot;, OR equals &quot;material&quot;. This is critical for batch grouping to work with process types like &quot;Sheetmetal cutting&quot; or &quot;Steel Tube cutting&quot;.</p>
</li>
<li><p><strong>BOM import uses two libraries</strong>: <code>xlsx</code> for reading spreadsheet data, and <code>exceljs</code> for extracting embedded images (thumbnails). They serve different purposes and both are needed.</p>
</li>
<li><p><strong>The worker queue mixes batched and individual items</strong> in a single prioritized list. Cutting operations are grouped into batches; everything else is individual. This is the &quot;Next Work&quot; endpoint.</p>
</li>
<li><p><strong>Operation status transitions</strong>: pending -&gt; in_progress -&gt; completed (or stalled from in_progress, then resumed back to in_progress). The &quot;skipped&quot; status exists for operations bypassed via inventory fulfillment.</p>
</li>
<li><p><strong>The task naming two-line format</strong> must use &quot; (double quote character) instead of &quot;in&quot; for inches in thickness. The thickness value should be displayed as it comes from the BOM with this one normalization.</p>
</li>
<li><p><strong>Subtitle parts are joined with &quot; - &quot; (space dash space)</strong> as separators. Empty parts are omitted entirely.</p>
</li>
<li><p><strong>Assembly blocking</strong>: An operation on an &quot;assembly&quot; type component is blocked until ALL child components (and their children, recursively) have status &quot;completed&quot;.</p>
</li>
<li><p><strong>Session-based auth</strong> means the server must support sessions. The worker login flow explicitly prevents admin role users from using it (they must use PIN).</p>
</li>
<li><p><strong>Inventory auto-numbering</strong> assigns the next available number in the category range when creating new items.</p>
</li>
<li><p><strong>Reorder alerts deduplicate</strong> - the system should check for existing pending alerts before creating duplicates.</p>
</li>
<li><p><strong>BOM update (re-import) preserves work</strong> - when updating a BOM, existing operations with status in_progress or completed must not be deleted. Only add new components, remove components that no longer exist in the BOM, and update metadata on existing ones.</p>
</li>
<li><p><strong>The priority recalculation</strong> processes all active (non-template, non-archived) jobs and updates every operation&#39;s priorityScore and priorityTier based on current date vs. due date.</p>
</li>
</ol>
</body></html>

View File

@ -0,0 +1 @@
{}

29095
replit_data/components.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
{
"count": 1,
"data": [
{
"id": 1,
"name": "TIG Welding Rods",
"quantity": 50,
"reorderLevel": 20,
"unit": "kg"
}
]
}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-fCLaIajI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aOXWrruY.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,27 @@
{
"count": 1,
"data": [
{
"id": 1,
"itemNumber": 5000,
"name": "ghghgh",
"description": "",
"category": "material",
"sku": "",
"unit": "pcs",
"quantity": 0,
"restockPoint": 10,
"reorderPoint": 5,
"reorderQuantity": 20,
"storageLocation": "",
"supplierName": "",
"supplierContact": "",
"supplierEmail": "",
"unitCost": 0,
"minimumOrderQuantity": 0,
"leadTimeDays": 0,
"lastRestockDate": null,
"createdAt": "2026-01-12T14:12:25.972Z"
}
]
}

41
replit_data/jobs.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,4 @@
{
"count": 0,
"data": []
}

1013
replit_data/operations.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
{
"count": 10,
"data": [
{
"id": 1,
"name": "sheetmetal cutting",
"orderIndex": 1,
"createdAt": "2026-01-11T16:30:06.420Z"
},
{
"id": 2,
"name": "bar/tube cutting",
"orderIndex": 2,
"createdAt": "2026-01-11T16:30:14.370Z"
},
{
"id": 3,
"name": "bending",
"orderIndex": 3,
"createdAt": "2026-01-11T16:30:26.992Z"
},
{
"id": 4,
"name": "fabrication",
"orderIndex": 4,
"createdAt": "2026-01-11T16:31:39.741Z"
},
{
"id": 5,
"name": "welding",
"orderIndex": 5,
"createdAt": "2026-01-11T16:31:46.010Z"
},
{
"id": 6,
"name": "electrical",
"orderIndex": 6,
"createdAt": "2026-01-11T16:31:52.729Z"
},
{
"id": 7,
"name": "assembly",
"orderIndex": 7,
"createdAt": "2026-01-11T16:31:58.973Z"
},
{
"id": 8,
"name": "rolling",
"orderIndex": 8,
"createdAt": "2026-01-11T16:39:05.843Z"
},
{
"id": 9,
"name": "finishing",
"orderIndex": 9,
"createdAt": "2026-01-11T16:40:58.715Z"
},
{
"id": 10,
"name": "machining",
"orderIndex": 10,
"createdAt": "2026-01-11T16:43:02.837Z"
}
]
}

View File

@ -0,0 +1,4 @@
{
"count": 0,
"data": []
}

View File

@ -0,0 +1 @@
[{"id":1,"name":"Measure stock","status":"completed","componentId":1},{"id":2,"name":"Set saw angle","status":"completed","componentId":1},{"id":3,"name":"Fixture setup","status":"completed","componentId":2},{"id":4,"name":"Root pass","status":"pending","componentId":2},{"id":5,"name":"Check DXF file","status":"pending","componentId":3}]

View File

@ -0,0 +1 @@
{"message":"Unauthorized"}

72
replit_data/users.json Normal file
View File

@ -0,0 +1,72 @@
{
"count": 4,
"data": [
{
"id": "1571ac82-0762-4763-83e7-a216a6f3ed82",
"email": null,
"firstName": null,
"lastName": null,
"profileImageUrl": null,
"username": "Sam",
"role": "worker",
"assignedProcesses": [
"fabrication",
"welding",
"rolling"
],
"hasCompletedTutorial": false,
"pinHash": null,
"createdAt": "2026-01-11T16:27:30.536Z",
"updatedAt": "2026-01-11T16:42:29.719Z"
},
{
"id": "f36f5e69-d8f2-4416-adcf-306b049b3d2b",
"email": null,
"firstName": null,
"lastName": null,
"profileImageUrl": null,
"username": "Derik",
"role": "worker",
"assignedProcesses": [
"sheetmetal cutting",
"bar/tube cutting",
"bending",
"finishing"
],
"hasCompletedTutorial": false,
"pinHash": null,
"createdAt": "2026-01-11T16:42:42.127Z",
"updatedAt": "2026-01-11T16:42:42.127Z"
},
{
"id": "b901b15b-0ee6-493f-859c-1e8fd004f575",
"email": null,
"firstName": null,
"lastName": null,
"profileImageUrl": null,
"username": "Daniel",
"role": "worker",
"assignedProcesses": [
"machining"
],
"hasCompletedTutorial": false,
"pinHash": null,
"createdAt": "2026-01-11T16:43:11.659Z",
"updatedAt": "2026-01-11T16:43:11.659Z"
},
{
"id": "9b906950-cb85-437f-b9e3-2a253ffd5ddf",
"email": null,
"firstName": null,
"lastName": null,
"profileImageUrl": null,
"username": "Admin User",
"role": "admin",
"assignedProcesses": [],
"hasCompletedTutorial": true,
"pinHash": "[REDACTED]",
"createdAt": "2026-01-11T16:27:30.489Z",
"updatedAt": "2026-01-12T13:16:58.877Z"
}
]
}

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-fCLaIajI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aOXWrruY.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

16
replit_db_export.json Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Architects+Daughter&family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Fira+Code:wght@300..700&family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,opsz,wght@0,18..144,300..900;1,18..144,300..900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Outfit:wght@100..900&family=Oxanium:wght@200..800&family=Playfair+Display:ital,wght@0,400..900;1,400..900&family=Plus+Jakarta+Sans:ital,wght@0,200..800;1,200..800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100..900;1,100..900&family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&family=Space+Grotesk:wght@300..700&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-fCLaIajI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-aOXWrruY.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

82
scan.php Normal file
View File

@ -0,0 +1,82 @@
<?php
session_start();
require 'db/config.php';
if (!isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
$role = $_SESSION['role'];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Scan QR/Barcode</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f8fafc;
--card-bg: #ffffff;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--sidebar-w: 260px;
}
body { font-family: 'Inter', sans-serif; background-color: var(--bg); color: var(--text); }
.sidebar { width: var(--sidebar-w); position: fixed; top: 0; bottom: 0; left: 0; background: var(--card-bg); border-right: 1px solid #e2e8f0; padding: 2rem 1.5rem; }
.main-content { margin-left: var(--sidebar-w); padding: 2rem; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
.scanner-box { width: 100%; max-width: 500px; background: white; border-radius: 16px; padding: 2rem; text-align: center; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1); }
.camera-placeholder { width: 100%; height: 300px; background: #0f172a; border-radius: 12px; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; }
.scan-line { width: 100%; height: 2px; background: #22c55e; position: absolute; top: 50%; box-shadow: 0 0 10px 2px #22c55e; animation: scan 2s infinite linear; }
@keyframes scan { 0% { top: 0; } 50% { top: 100%; } 100% { top: 0; } }
</style>
</head>
<body>
<div class="sidebar">
<h4 class="fw-bold mb-5" style="color: var(--primary);"><i class="bi bi-cpu me-2"></i>M-TRACK</h4>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<?php if ($role === 'admin'): ?>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<?php else: ?>
<a class="nav-link" href="dashboard.php"><i class="bi bi-list-task me-2"></i> My Queue</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Board View</a>
<?php endif; ?>
<a class="nav-link active" href="scan.php"><i class="bi bi-upc-scan me-2"></i> Scan</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="scanner-box">
<h3 class="fw-bold mb-3">Scan Kanban Card</h3>
<p class="text-muted mb-4">Position the QR or Barcode within the frame to trigger a reorder or update job status.</p>
<div class="camera-placeholder">
<span class="text-white-50"><i class="bi bi-camera-video me-2"></i> Camera Feed Active</span>
<div class="scan-line"></div>
</div>
<button class="btn btn-primary w-100 btn-lg rounded-pill" onclick="simulateScan()">
<i class="bi bi-upc-scan me-2"></i> Simulate Scan
</button>
</div>
</div>
<script>
function simulateScan() {
alert("Scanned! (This is a simulation). In a real environment, this would parse the QR code payload and trigger an API call to update inventory or job status.");
}
</script>
</body>
</html>

185
shop_floor.php Normal file
View File

@ -0,0 +1,185 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id'])) {
header('Location: index.php');
exit;
}
$role = $_SESSION['role'];
$userId = $_SESSION['user_id'];
// Fetch all operations with context
$sql = "
SELECT o.*, c.name as component_name, j.name as job_name, j.serial_number, u.name as worker_name
FROM operations o
JOIN components c ON o.component_id = c.id
JOIN jobs j ON c.job_id = j.id
LEFT JOIN users u ON o.assigned_worker_id = u.id
WHERE o.status != 'completed'
ORDER BY o.priority_tier DESC, o.order_index ASC, o.created_at ASC
";
$operations = $db->query($sql)->fetchAll();
// Group by status for Kanban
$kanban = [
'pending' => [],
'in_progress' => [],
'stalled' => []
];
foreach ($operations as $op) {
if (isset($kanban[$op['status']])) {
$kanban[$op['status']][] = $op;
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Shop Floor</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f8fafc;
--card-bg: #ffffff;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--sidebar-w: 260px;
}
body { font-family: 'Inter', sans-serif; background-color: var(--bg); color: var(--text); overflow-x: hidden; }
.sidebar { width: var(--sidebar-w); position: fixed; top: 0; bottom: 0; left: 0; background: var(--card-bg); border-right: 1px solid #e2e8f0; padding: 2rem 1.5rem; z-index: 10; }
.main-content { margin-left: var(--sidebar-w); padding: 2rem; min-height: 100vh; }
.top-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; }
.kanban-board { display: flex; gap: 1.5rem; align-items: flex-start; overflow-x: auto; padding-bottom: 1rem; }
.kanban-col { background: #e2e8f0; border-radius: 12px; min-width: 320px; padding: 1rem; flex-shrink: 0; }
.kanban-card { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.05); cursor: grab; border-left: 4px solid var(--accent); transition: transform 0.2s; }
.kanban-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0,0,0,0.1); }
.kanban-card.stalled { border-left-color: #ef4444; }
.kanban-card.in_progress { border-left-color: #10b981; }
.kanban-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem; color: var(--primary); }
.kanban-meta { font-size: 0.8rem; color: #64748b; }
.badge-tier { font-size: 0.7rem; padding: 0.25em 0.5em; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
.tier-urgent { background: #fee2e2; color: #b91c1c; }
.tier-high { background: #fef3c7; color: #c2410c; }
.tier-normal { background: #f1f5f9; color: #475569; }
.tier-low { background: #f8fafc; color: #94a3b8; border: 1px solid #e2e8f0; }
</style>
</head>
<body>
<div class="sidebar">
<h4 class="fw-bold mb-5" style="color: var(--primary);"><i class="bi bi-cpu me-2"></i>M-TRACK</h4>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<?php if ($role === 'admin'): ?>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link active" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<?php else: ?>
<a class="nav-link" href="dashboard.php"><i class="bi bi-list-task me-2"></i> My Queue</a>
<a class="nav-link active" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Board View</a>
<?php endif; ?>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="top-bar">
<div>
<h2 class="h3 fw-bold mb-1">Shop Floor</h2>
<p class="text-muted mb-0">Drag and drop operations to update status</p>
</div>
</div>
<div class="kanban-board">
<!-- Pending Column -->
<div class="kanban-col">
<h5 class="fw-bold mb-3 px-2">Pending <span class="badge bg-secondary rounded-pill float-end"><?= count($kanban['pending']) ?></span></h5>
<?php foreach ($kanban['pending'] as $op): ?>
<div class="kanban-card">
<div class="d-flex justify-content-between mb-1">
<span class="badge-tier tier-<?= $op['priority_tier'] ?>"><?= $op['priority_tier'] ?></span>
<span class="badge bg-light text-dark border"><i class="bi bi-tag"></i> <?= htmlspecialchars($op['process_type']) ?></span>
</div>
<div class="kanban-title"><?= htmlspecialchars($op['name']) ?></div>
<div class="kanban-meta mb-2">
<div><i class="bi bi-box"></i> <?= htmlspecialchars($op['component_name']) ?></div>
<div><i class="bi bi-briefcase"></i> <?= htmlspecialchars($op['job_name']) ?> (<?= htmlspecialchars($op['serial_number']) ?>)</div>
</div>
<div class="mt-2 text-end">
<button class="btn btn-sm btn-outline-primary rounded-pill px-3">Start</button>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($kanban['pending'])): ?>
<div class="text-muted text-center p-3 small">No pending operations</div>
<?php endif; ?>
</div>
<!-- In Progress Column -->
<div class="kanban-col">
<h5 class="fw-bold mb-3 px-2">In Progress <span class="badge bg-success rounded-pill float-end"><?= count($kanban['in_progress']) ?></span></h5>
<?php foreach ($kanban['in_progress'] as $op): ?>
<div class="kanban-card in_progress">
<div class="d-flex justify-content-between mb-1">
<span class="badge-tier tier-<?= $op['priority_tier'] ?>"><?= $op['priority_tier'] ?></span>
<span class="badge bg-light text-dark border"><i class="bi bi-person"></i> <?= htmlspecialchars($op['worker_name'] ?? 'Unknown') ?></span>
</div>
<div class="kanban-title"><?= htmlspecialchars($op['name']) ?></div>
<div class="kanban-meta mb-2">
<div><i class="bi bi-box"></i> <?= htmlspecialchars($op['component_name']) ?></div>
<div><i class="bi bi-briefcase"></i> <?= htmlspecialchars($op['job_name']) ?> (<?= htmlspecialchars($op['serial_number']) ?>)</div>
</div>
<div class="mt-2 d-flex justify-content-between">
<button class="btn btn-sm btn-outline-danger rounded-pill px-3">Stall</button>
<button class="btn btn-sm btn-success rounded-pill px-3">Done</button>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($kanban['in_progress'])): ?>
<div class="text-muted text-center p-3 small">No operations in progress</div>
<?php endif; ?>
</div>
<!-- Stalled Column -->
<div class="kanban-col">
<h5 class="fw-bold mb-3 px-2">Stalled <span class="badge bg-danger rounded-pill float-end"><?= count($kanban['stalled']) ?></span></h5>
<?php foreach ($kanban['stalled'] as $op): ?>
<div class="kanban-card stalled">
<div class="d-flex justify-content-between mb-1">
<span class="badge-tier tier-<?= $op['priority_tier'] ?>"><?= $op['priority_tier'] ?></span>
<span class="badge bg-light text-dark border"><i class="bi bi-exclamation-triangle"></i> Issue</span>
</div>
<div class="kanban-title"><?= htmlspecialchars($op['name']) ?></div>
<div class="kanban-meta mb-2">
<div><i class="bi bi-box"></i> <?= htmlspecialchars($op['component_name']) ?></div>
<div class="text-danger mt-1"><i class="bi bi-chat-dots"></i> <?= htmlspecialchars($op['blocked_reason'] ?? 'No reason provided') ?></div>
</div>
<div class="mt-2 text-end">
<button class="btn btn-sm btn-outline-secondary rounded-pill px-3">Resume</button>
</div>
</div>
<?php endforeach; ?>
<?php if (empty($kanban['stalled'])): ?>
<div class="text-muted text-center p-3 small">No stalled operations</div>
<?php endif; ?>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

1
subtasks.json Normal file
View File

@ -0,0 +1 @@
[{"id":1,"name":"Measure stock","status":"completed","componentId":1},{"id":2,"name":"Set saw angle","status":"completed","componentId":1},{"id":3,"name":"Fixture setup","status":"completed","componentId":2},{"id":4,"name":"Root pass","status":"pending","componentId":2},{"id":5,"name":"Check DXF file","status":"pending","componentId":3}]

114
time_study.php Normal file
View File

@ -0,0 +1,114 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
header('Location: index.php');
exit;
}
$role = $_SESSION['role'];
// Fetch Time Study events
$sql = "
SELECT t.*, o.name as op_name, u.name as worker_name, j.name as job_name
FROM time_study_events t
JOIN operations o ON t.operation_id = o.id
JOIN components c ON o.component_id = c.id
JOIN jobs j ON c.job_id = j.id
JOIN users u ON t.user_id = u.id
ORDER BY t.timestamp DESC
LIMIT 100
";
$events = $db->query($sql)->fetchAll();
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | Time Study</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f8fafc;
--card-bg: #ffffff;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--sidebar-w: 260px;
}
body { font-family: 'Inter', sans-serif; background-color: var(--bg); color: var(--text); }
.sidebar { width: var(--sidebar-w); position: fixed; top: 0; bottom: 0; left: 0; background: var(--card-bg); border-right: 1px solid #e2e8f0; padding: 2rem 1.5rem; }
.main-content { margin-left: var(--sidebar-w); padding: 2rem; }
.card { border: none; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }
.event-start { color: #3b82f6; background: #eff6ff; padding: 2px 6px; border-radius: 4px; }
.event-completed { color: #10b981; background: #ecfdf5; padding: 2px 6px; border-radius: 4px; }
.event-stalled { color: #ef4444; background: #fef2f2; padding: 2px 6px; border-radius: 4px; }
.event-resume { color: #f59e0b; background: #fffbeb; padding: 2px 6px; border-radius: 4px; }
</style>
</head>
<body>
<div class="sidebar">
<h4 class="fw-bold mb-5" style="color: var(--primary);"><i class="bi bi-cpu me-2"></i>M-TRACK</h4>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<a class="nav-link active" href="time_study.php"><i class="bi bi-clock-history me-2"></i> Time Study</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="h3 fw-bold mb-0">Time Study Analytics</h2>
<button class="btn btn-outline-primary"><i class="bi bi-download"></i> Export CSV</button>
</div>
<div class="card p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Timestamp</th>
<th>Worker</th>
<th>Job</th>
<th>Operation</th>
<th>Event</th>
<th>Reason / Note</th>
</tr>
</thead>
<tbody>
<?php foreach ($events as $e): ?>
<tr>
<td><?= date('Y-m-d H:i:s', strtotime($e['timestamp'])) ?></td>
<td class="fw-medium"><?= htmlspecialchars($e['worker_name']) ?></td>
<td><?= htmlspecialchars($e['job_name']) ?></td>
<td><?= htmlspecialchars($e['op_name']) ?></td>
<td>
<span class="event-<?= $e['event_type'] ?> small fw-bold text-uppercase">
<?= htmlspecialchars($e['event_type']) ?>
</span>
</td>
<td class="text-muted small"><?= htmlspecialchars($e['reason'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
<?php if(empty($events)): ?>
<tr><td colspan="6" class="text-center py-4 text-muted">No time study events recorded yet.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>

40
ui_review.md Normal file
View File

@ -0,0 +1,40 @@
# M-TRACK UI & Frontend Architecture Review
Based on the Replit App Summary, here is a structured breakdown of the UI components we need to build for the Flatlogic version of M-TRACK:
## 1. Authentication & Onboarding Flow
- **Landing Page:** A unified entry point displaying a user selection list.
- **Two-Tier Login:** Workers get frictionless 1-click access; Admins are prompted for a 4-6 digit hashed PIN.
- **First-Time Setup:** A "Bootstrap" mode if no Admin exists.
- **Tutorial Overlay:** An 8-step interactive onboarding flow (using context/state) for first-time logins.
## 2. Dashboard Interface
- **Stat Cards:** 4 clickable metric widgets (Active Jobs, Completed Units, Low Stock Alert, Total Jobs).
- **Charts:** A bar chart visualizing Job Statuses (Planned vs. In Progress vs. Completed).
- **Recent Activity:** A list of the 5 most recent jobs with status badges.
- **Work Management Panel:** Highlights in-progress work. Workers see their own queue; Admins can filter by any worker.
## 3. Jobs & BOM Management
- **Job Lists:** Filterable views for Active Jobs, Archived Jobs, and Job Templates.
- **Job Creation Modal:** Options to create manually or instantiate from a template.
- **BOM Import Workflow:** A critical UI for uploading Excel/CSV files, displaying extracted thumbnail images, and showing an import summary.
- **Component Tree View:** A hierarchical UI (Assemblies -> Sub-assemblies -> Parts) showing order indexes, quantities, and attached operations.
## 4. Shop Floor & Worker Queue (The Core Engine)
- **"Next Work" Unified Queue:** A prioritized, interleaved list of batched tasks (e.g., grouped cutting operations) and individual tasks.
- **Standardized Task Cards:**
- Line 1: `[Process] [Product] [Main Assembly] [Sub-Assembly]`
- Line 2: `[Thickness] [Material] - [Customer] - [Serial Number]`
- **Task Controls:** Start, Stall (with mandatory reason prompt), Resume, and Done buttons.
- **Kanban Board:** A drag-and-drop interface mapping components across Pending, In Progress, and Completed columns.
## 5. Inventory Management
- **Tabbed Data Tables:** Separated views for Materials, Manufactured Parts, and Consumables.
- **Stock Adjustment Modal:** Interface to update quantities with transaction reasons (restock, consume, etc.).
- **Reorder Alerts:** A dedicated view for Admins to approve/dismiss alerts, with a notification badge in the sidebar.
- **QR / Barcode Integration:** UI to generate printable Kanban cards and a camera scanner view to trigger reorders.
## 6. Admin Settings & Analytics
- **User Management:** Assign roles, reset PINs, and map specific process types (e.g., cutting, welding) to individual workers.
- **Process Types Config:** A simple list manager to add/remove manufacturing process categories.
- **Time Study Reports:** Analytical tables filtering work events (start, stall, complete) by user/job to calculate active vs. stalled time.

1
users.json Normal file
View File

@ -0,0 +1 @@
[{"id":"1571ac82-0762-4763-83e7-a216a6f3ed82","email":null,"firstName":null,"lastName":null,"profileImageUrl":null,"username":"Sam","role":"worker","assignedProcesses":["fabrication","welding","rolling"],"hasCompletedTutorial":false,"pinHash":null,"createdAt":"2026-01-11T16:27:30.536Z","updatedAt":"2026-01-11T16:42:29.719Z"},{"id":"f36f5e69-d8f2-4416-adcf-306b049b3d2b","email":null,"firstName":null,"lastName":null,"profileImageUrl":null,"username":"Derik","role":"worker","assignedProcesses":["sheetmetal cutting","bar/tube cutting","bending","finishing"],"hasCompletedTutorial":false,"pinHash":null,"createdAt":"2026-01-11T16:42:42.127Z","updatedAt":"2026-01-11T16:42:42.127Z"},{"id":"b901b15b-0ee6-493f-859c-1e8fd004f575","email":null,"firstName":null,"lastName":null,"profileImageUrl":null,"username":"Daniel","role":"worker","assignedProcesses":["machining"],"hasCompletedTutorial":false,"pinHash":null,"createdAt":"2026-01-11T16:43:11.659Z","updatedAt":"2026-01-11T16:43:11.659Z"},{"id":"9b906950-cb85-437f-b9e3-2a253ffd5ddf","email":null,"firstName":null,"lastName":null,"profileImageUrl":null,"username":"Admin User","role":"admin","assignedProcesses":[],"hasCompletedTutorial":true,"pinHash":"$2b$10$wPkYDWj/APEJGfi2lW3HbOAryaFXki5f1D9UrFyMUhaDSJP057Ge6","createdAt":"2026-01-11T16:27:30.489Z","updatedAt":"2026-01-12T13:16:58.877Z"}]

256
users.php Normal file
View File

@ -0,0 +1,256 @@
<?php
session_start();
require 'db/config.php';
$db = db();
if (!isset($_SESSION['user_id']) || $_SESSION['role'] !== 'admin') {
header('Location: index.php');
exit;
}
// Handle User Creation/Update
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_user'])) {
$id = $_POST['user_id'] ?? null;
$name = $_POST['name'];
$role = $_POST['role'];
$processes = json_encode($_POST['processes'] ?? []);
$pin = $_POST['pin'] ?? '';
if ($id) {
// Update
$sql = "UPDATE users SET name = ?, role = ?, assigned_processes = ? WHERE id = ?";
$params = [$name, $role, $processes, $id];
$db->prepare($sql)->execute($params);
if ($role === 'admin' && !empty($pin)) {
$pinHash = password_hash($pin, PASSWORD_BCRYPT);
$db->prepare("UPDATE users SET pin_hash = ? WHERE id = ?")->execute([$pinHash, $id]);
}
} else {
// Create
$pinHash = ($role === 'admin' && !empty($pin)) ? password_hash($pin, PASSWORD_BCRYPT) : null;
$db->prepare("INSERT INTO users (name, role, assigned_processes, pin_hash) VALUES (?, ?, ?, ?)")
->execute([$name, $role, $processes, $pinHash]);
}
header("Location: users.php?success=1");
exit;
}
$users = $db->query("SELECT * FROM users ORDER BY role ASC, name ASC")->fetchAll();
$processTypes = ['cutting', 'welding', 'bending', 'assembly', 'inspection'];
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>M-TRACK | User Management</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.0/font/bootstrap-icons.css">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--sidebar-width: 240px;
--bg: #f8fafc;
--primary: #1e293b;
--accent: #3b82f6;
--text: #334155;
--border: #e2e8f0;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.sidebar {
width: var(--sidebar-width);
position: fixed;
top: 0;
bottom: 0;
left: 0;
background-color: var(--primary);
color: white;
z-index: 1000;
padding: 1.5rem 1rem;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2.5rem;
}
.card {
background: white;
border: 1px solid var(--border);
border-radius: 4px;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
margin-bottom: 1.5rem;
}
.badge-process {
background: #e2e8f0;
color: #475569;
font-weight: 600;
font-size: 0.75rem;
padding: 0.25rem 0.625rem;
border-radius: 4px;
margin-right: 0.25rem;
margin-bottom: 0.25rem;
display: inline-block;
}
</style>
</head>
<body>
<div class="sidebar">
<h2>M-TRACK</h2>
<nav class="nav nav-pills flex-column">
<a class="nav-link" href="dashboard.php"><i class="bi bi-grid-fill me-2"></i> Dashboard</a>
<a class="nav-link" href="jobs.php"><i class="bi bi-briefcase me-2"></i> Jobs</a>
<a class="nav-link" href="shop_floor.php"><i class="bi bi-kanban me-2"></i> Shop Floor</a>
<a class="nav-link" href="inventory.php"><i class="bi bi-boxes me-2"></i> Inventory</a>
<a class="nav-link active" href="users.php"><i class="bi bi-people me-2"></i> Users</a>
<hr class="my-4 border-secondary opacity-25">
<a class="nav-link text-danger" href="logout.php"><i class="bi bi-box-arrow-right me-2"></i> Logout</a>
</nav>
</div>
<div class="main-content">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>User Management</h1>
<button class="btn btn-primary" onclick="resetUserModal()" data-bs-toggle="modal" data-bs-target="#userModal">
<i class="bi bi-person-plus-fill me-1"></i> New User
</button>
</div>
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Role</th>
<th>Assigned Processes</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<?php $procs = json_decode($user['assigned_processes'] ?? '[]', true); ?>
<tr>
<td><div class="fw-bold"><?= htmlspecialchars($user['name']) ?></div></td>
<td><span class="badge bg-<?= $user['role'] === 'admin' ? 'dark' : 'light text-dark border' ?> text-uppercase" style="font-size: 0.7rem;"><?= $user['role'] ?></span></td>
<td>
<?php if ($user['role'] === 'worker'): ?>
<?php foreach ($procs as $p): ?>
<span class="badge-process"><?= strtoupper($p) ?></span>
<?php endforeach; ?>
<?php if (empty($procs)): ?>
<span class="text-muted small">None assigned</span>
<?php endif; ?>
<?php else: ?>
<span class="text-muted small"></span>
<?php endif; ?>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary" onclick='editUser(<?= json_encode($user) ?>)' data-bs-toggle="modal" data-bs-target="#userModal">Edit</button>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- User Modal -->
<div class="modal fade" id="userModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" class="modal-content" id="userForm">
<input type="hidden" name="user_id" id="user_id">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">New User</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Full Name</label>
<input type="text" name="name" id="user_name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<select name="role" id="user_role" class="form-select" onchange="toggleRoleFields()">
<option value="worker">Worker</option>
<option value="admin">Admin</option>
</select>
</div>
<div id="worker_fields">
<label class="form-label d-block">Assigned Processes</label>
<div class="row">
<?php foreach ($processTypes as $pt): ?>
<div class="col-6 mb-2">
<div class="form-check">
<input class="form-check-input proc-checkbox" type="checkbox" name="processes[]" value="<?= $pt ?>" id="proc_<?= $pt ?>">
<label class="form-check-label text-capitalize" for="proc_<?= $pt ?>">
<?= $pt ?>
</label>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div id="admin_fields" style="display:none;">
<div class="mb-3">
<label class="form-label">PIN (4-6 digits)</label>
<input type="password" name="pin" id="user_pin" class="form-control" placeholder="Leave blank to keep current PIN if editing">
<small class="text-muted">Admins must enter this PIN to log in.</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" name="save_user" class="btn btn-primary">Save User</button>
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function toggleRoleFields() {
const role = document.getElementById('user_role').value;
document.getElementById('worker_fields').style.display = role === 'worker' ? 'block' : 'none';
document.getElementById('admin_fields').style.display = role === 'admin' ? 'block' : 'none';
}
function resetUserModal() {
document.getElementById('userForm').reset();
document.getElementById('user_id').value = '';
document.getElementById('modalTitle').innerText = 'New User';
toggleRoleFields();
}
function editUser(user) {
document.getElementById('user_id').value = user.id;
document.getElementById('user_name').value = user.name;
document.getElementById('user_role').value = user.role;
document.getElementById('modalTitle').innerText = 'Edit User: ' + user.name;
// Reset checkboxes
document.querySelectorAll('.proc-checkbox').forEach(cb => cb.checked = false);
if (user.assigned_processes) {
const procs = JSON.parse(user.assigned_processes);
procs.forEach(p => {
const cb = document.getElementById('proc_' + p);
if (cb) cb.checked = true;
});
}
toggleRoleFields();
}
</script>
</body>
</html>