Explorar el Código

Rewrite on SQLite with job history and threaded messages

Replaces the MySQL/contenteditable site with a PHP + PDO SQLite app:

- Schema (lib/db.php): vendors, jobs, job_history, messages; auto-migrates
  on first request and seeds Bill as the first vendor.
- Per-vendor message threads with author + timestamp on every post,
  replacing the single shared overwritable blob.
- Audit log for ack/status transitions (acknowledge, mark_finished,
  mark_shipped, mark_received) recording old/new/actor in job_history.
- Page-based identity: PDQ.php is ICG, vendor.php?v=<slug> is the
  subcontractor. Bill.php kept as a 302 redirect to vendor.php?v=bill.
- Prepared statements throughout; vendor-side requests are scoped to
  their own jobs; field edits restricted to ICG; column whitelist on
  the update endpoint.
- Vanilla JS front-end (assets/app.js) using fetch; focus-gated polling
  replaces the manual dontreload flag; jQuery dependency dropped.
- Multi-subcontractor ready: jobs route through vendor_id, ICG view
  has an "Add job for <vendor>" picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bernn hace 1 semana
padre
commit
1640b0611c
Se han modificado 21 ficheros con 1087 adiciones y 540 borrados
  1. 5 0
      .gitignore
  2. 4 153
      Bill.php
  3. 0 30
      ICGstable.php
  4. 64 177
      PDQ.php
  5. 0 12
      PDQAddOne.php
  6. 0 96
      PDQUpdates.php
  7. 0 33
      PDQacknowledge.php
  8. 0 10
      PDQmessage.php
  9. 193 0
      assets/app.css
  10. 253 0
      assets/app.js
  11. 0 29
      billstable.php
  12. 29 0
      bin/jobs_add.php
  13. 9 0
      bin/jobs_table.php
  14. 88 0
      bin/jobs_update.php
  15. 20 0
      bin/messages_list.php
  16. 33 0
      bin/messages_post.php
  17. 89 0
      lib/db.php
  18. 47 0
      lib/identity.php
  19. 49 0
      lib/jobs.php
  20. 153 0
      lib/render.php
  21. 51 0
      vendor.php

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+data/
+*.sqlite
+*.sqlite-journal
+*.sqlite-wal
+*.sqlite-shm

+ 4 - 153
Bill.php

@@ -1,153 +1,4 @@
-<?php include_once('pdqconnect.inc'); ?>
-<html>
-<head>
-<script src="jquery.min.js"></script>
-<script>$(document).ready(function(){   // jquery stuff in here
-	Reload();
-	setInterval(function(){
-		if(dontreload==0){
-			Reload();
-		}
-	},60000);  // 1000 = 1 second
-		$( "#messages" ).focusout(function() {
-			var newData=$(this).html();
-				billsMessage(newData);
-				$(this).attr("contenteditable", "false");
-				$(this).css("background-color", "orange");
-		})
-
-		$( "#messages" ).dblclick(function() {
-			dontreload=1;
-			globalOldData = $(this).html();
-			if($(this).attr("contenteditable")=="true"){
-				return;
-			} else {
-				$(this).css("background-color", "yellow");
-				$(this).attr("contenteditable", "true");
-				
-			}
-		})
-	
-})</script>
-
-<script>
-function billsMessage(newmessage){
-	var sendMe = 'newmessage=' + encodeURIComponent(newmessage);
-
-	$.post("bin/PDQmessage.php",sendMe,
-			function(data,status){
-			dontreload=0;
-			if(data=='Success' ){
-				$("#messages").css("background-color", '');
-			} else {
-				$("#messages").css("background-color", 'red');
-				alert("error: " + data);
-			}
-		}
-	);
-}
-var globalOldData;
-var dontreload=0;
-function sendUpdates(subKID, column, current, callingObject){
-	var sendMe = "subKID=" + encodeURIComponent(subKID) + "&column=" +  encodeURIComponent(column)+ "&current=" +  encodeURIComponent(current);
-
-	$.post("bin/PDQacknowledge.php",sendMe,
-		function(data,status){
-			dontreload=0;
-			if(data=="Success" ){
-				$(callingObject).html("");
-				$(callingObject).css("background-color", "");
-				Reload();
-			} else {
-				$(callingObject).css("background-color", "red");
-				alert("error: " + data);
-			}
-		}
-	);
-}
-
-function Reload(){
-	$.get( "bin/billstable.php", function( data ) {
-		$( "#billstable" ).html( data );
-			$( ".updatableDiv" ).dblclick(function() {
-				//alert('wh');
-				//dontreload=1;
-				globalOldData = $(this).html();
-				var subKID=$(this).attr("data-subkid");
-				var column=$(this).attr("data-column");
-				sendUpdates(subKID, column, $(this).html(), this);
-				$(this).css("background-color", "orange");
-			})
-			
-			// put current time in caption
-			var currentdate = new Date(); 
-			var datetime = "Last Sync: " + (currentdate.getMonth()+1) + "/" + currentdate.getDate() + " : " +currentdate.getHours() + ":" + currentdate.getMinutes();  
-			$("#tableCap").html(datetime);
-			$("#tableCap").css("font-size","26pt");
-	});
-
-}
-</script>
-
-<style>
-#messages{
-	font-size:24px;
-	min-height:20px;
-	border:1px solid;
-}
-body{
-	margin-left:5%;
-}
-.smaller{
-	font-size:18pt;
-}
-.right{
-	text-align:right;
-}
-.center{
-	text-align:center;
-}
-.tc{
-	display:table-cell;
-	padding-left:25px;
-	font-size:16pt;
-}
-th{
-	padding-left:25px;
-	padding-right:25px;
-	font-size:30pt;
-}
-td{
-	padding-left:25px;
-	padding-right:25px;
-	font-size:30pt;
-}
-tr:hover{
-	background-color:rgba(49,13,84,.08);
-}
-.colorRow{
-	background-color:rgba(49,13,84,.08);
-}
-.rjust{
-	text-align:right;
-}
-
-.smalltext{
-	font-size:10pt;
-}
-
-</style>
-</head>
-<body>
-<div id='messages' contenteditable="False" >
-<?php
-$sql = "select message from PDQMessage where PDQMid=0";
-$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-$row=mysqli_fetch_assoc($rs);
-echo $row['message'];
-?>
-</div>
-<div id='billstable'>
-</div>
-</body>
-</html>
+<?php
+// Preserves Bill's existing bookmark — redirect to the parameterized vendor page.
+header('Location: vendor.php?v=bill', true, 302);
+exit;

+ 0 - 30
ICGstable.php

@@ -1,30 +0,0 @@
-<?php
-include_once('pdqconnect.inc');
-
-$sql = "select subK.*, date_format(subKdue,'%c-%e') as dueDate from subK 
-		where (subKstatus != 'Received') or ( subKLastUpdate > DATE_SUB(CURDATE(), INTERVAL 4 DAY))
-		order by subKstatus asc, subKack desc, subKdue asc, subKjob";
-
-$rs=mysqli_query($mydb,$sql) or die(mysqli_error() . "<br>". $sql);
-echo "<table border=0>";
-echo "<tr><th>Job</th><th>Material</th><th>Description</th><th>Qty</th><th>Due Date</th><th>New</th><th>Status</th></tr>\n";
-while($row=mysqli_fetch_array($rs)){
-	if(strlen($row['subKjob'])>9){
-		$fontClass='smaller';
-	} else {
-		$fontClass='';
-	}
-
-	//$Vname = $row['Vname'];
-	$Qty = number_format($row['subKqty'],0);
-	echo "<tr><td data-subKID='{$row['subKID']}' data-column='subKjob' class='$fontClass updatableDiv'>{$row['subKjob']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKmaterial' class='updatableDiv'>{$row['subKmaterial']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKdesc' class='updatableDiv center'>{$row['subKdesc']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKqty' class='updatableDiv right'>$Qty</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKdue' class='updatableDiv center'>{$row['dueDate']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKack' class='updatableDiv center'>{$row['subKack']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKstatus' class='updateStatus center' >{$row['subKstatus']}</td></tr>\n";
-}
-echo "<caption>PDQ Schedule</caption></table>";
-
-?>

+ 64 - 177
PDQ.php

@@ -1,177 +1,64 @@
-<?php include_once('pdqconnect.inc'); ?>
-<html>
-<head>
-<script src="jquery.min.js"></script>
-<script>$(document).ready(function(){   // jquery stuff in here
-	Reload();
-	setInterval(function(){
-		if(dontreload==0){
-			Reload();
-		}
-	},120000);  // 1000 = 1 second
-
-
-		$( "#messages" ).focusout(function() {
-			var newData=$(this).html();
-				billsMessage(newData);
-				$(this).attr("contenteditable", "false");
-				$(this).css("background-color", "orange");
-		})
-
-		$( "#messages" ).dblclick(function() {
-			dontreload=1;
-			globalOldData = $(this).html();
-			if($(this).attr("contenteditable")=="true"){
-				return;
-			} else {
-				$(this).css("background-color", "yellow");
-				$(this).attr("contenteditable", "true");
-				
-			}
-		})
-})
-</script>
-
-<script>
-var globalOldData;
-var dontreload=0;
-
-function Reload(){
-	$.get( "bin/ICGstable.php", function( data ) {
-		$( "#billstable" ).html( data );
-		
-		$( ".updatableDiv" ).focusout(function() {
-			var subKID=$(this).attr("data-subKID");
-			var newData=$(this).html();
-			var column=$(this).attr("data-column");
-			if($(this).html()==globalOldData){
-				$(this).css("background-color", "");
-				$(this).attr("contenteditable", "false");
-				dontreload=0;
-			} else {
-				sendUpdates(subKID, newData, column, this);
-				$(this).attr("contenteditable", "false");
-				$(this).css("background-color", "orange");
-			}
-		})
-
-		$( ".updatableDiv" ).dblclick(function() {
-			dontreload=1;
-			globalOldData = $(this).html();
-			if($(this).attr("contenteditable")=="true"){
-				return;
-			} else {
-				$(this).css("background-color", "yellow");
-				$(this).attr("contenteditable", "true");
-				
-			}
-		})
-		$( ".updateStatus" ).dblclick(function() {
-			dontreload=1;
-			var column=$(this).attr("data-column");
-			var subKID=$(this).attr("data-subKID");
-			sendUpdates(subKID, $(this).html(), column, this);
-		})
-	})
-}
-function billsMessage(newmessage){
-	var sendMe = 'newmessage=' + encodeURIComponent(newmessage);
-
-	$.post("bin/PDQmessage.php",sendMe,
-			function(data,status){
-			dontreload=0;
-			if(data=='Success' ){
-				$("#messages").css("background-color", '');
-			} else {
-				$("#messages").css("background-color", 'red');
-				alert("error: " + data);
-			}
-		}
-	);
-}
-function sendUpdates(subKID, newData, column, callingObject){
-	var sendMe = 'subKID=' + encodeURIComponent(subKID) + '&newData=' + encodeURIComponent(newData) + '&column=' + encodeURIComponent(column);
-
-	$.post("bin/PDQUpdates.php",sendMe,
-			function(data,status){
-			dontreload=0;
-		
-			if(data=='Success' ){
-				$(callingObject).css("background-color", '');
-			} else {
-				$(callingObject).css("background-color", 'red');
-				alert("error: " + data);
-			}
-			if(column=="subKstatus"){Reload();}
-		}
-	);
-}
-</script>
-
-<style>
-#messages{
-	min-height:30px;
-	font-size:18pt;
-	border: 1px solid;
-
-}
-body{
-	margin-left:5%;
-}
-.smaller{
-	font-size:18pt;
-}
-.right{
-	text-align:right;
-}
-.center{
-	text-align:center;
-}
-.tc{
-	display:table-cell;
-	padding-left:25px;
-	font-size:16pt;
-}
-th{
-	padding-left:25px;
-	padding-right:25px;
-	font-size:30pt;
-}
-td{
-	padding-left:25px;
-	padding-right:25px;
-	font-size:30pt;
-}
-tr:hover{
-	background-color:rgba(49,13,84,.08);
-}
-.colorRow{
-	background-color:rgba(49,13,84,.08);
-}
-.rjust{
-	text-align:right;
-}
-
-.smalltext{
-	font-size:10pt;
-}
-
-</style>
-</head>
-
-<body>
-<div id='messages' contenteditable="False" >
-<?php
-$sql = "select message from PDQMessage where PDQMid=0";
-$rs=mysqli_query($mydb,$sql) or die(mysqli_error($mydb));
-$row=mysqli_fetch_assoc($rs);
-echo $row['message'];
-?>
-</div>
-<div> 
-<button onclick="location.href='bin/PDQAddOne.php'">Add One</button>
-</div>
-<div id='billstable'>
-</div>
-</body>
-</html>
+<?php
+require_once __DIR__ . '/lib/identity.php';
+require_once __DIR__ . '/lib/render.php';
+
+$actor = current_actor('ICG');
+$vendors = all_vendors();
+?><!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>PDQ Schedule</title>
+<link rel="stylesheet" href="assets/app.css">
+<script>
+window.PDQ = {
+    actor: 'ICG',
+    audience: 'ICG',
+    vendors: <?= json_encode(array_map(fn($v) => ['slug' => $v['slug'], 'name' => $v['name']], $vendors)) ?>,
+    pollMs: 120000
+};
+</script>
+<script src="assets/app.js" defer></script>
+</head>
+<body>
+
+<div class="topbar">
+    <h1>PDQ Schedule</h1>
+    <span class="who">Signed in as <strong>ICG</strong> &middot; <span id="sync-time">Loading…</span></span>
+</div>
+
+<div class="add-row">
+    <label for="add-job-vendor">Add job for:</label>
+    <select id="add-job-vendor">
+        <?php foreach ($vendors as $v): ?>
+            <option value="<?= h($v['slug']) ?>"><?= h($v['name']) ?></option>
+        <?php endforeach; ?>
+    </select>
+    <button id="add-job" class="btn btn-add">Add One</button>
+</div>
+
+<div class="jobs-wrap">
+    <div id="jobs-table"><?= render_jobs_table('ICG') ?></div>
+</div>
+
+<?php foreach ($vendors as $v):
+    $vid = (int) $v['id'];
+    $maxId = max_message_id($vid);
+?>
+<section class="thread" data-vendor="<?= h($v['slug']) ?>">
+    <h2>Messages with <?= h($v['name']) ?></h2>
+    <div class="thread-list" data-max-id="<?= $maxId ?>">
+        <?php
+        $rendered = render_messages($vid);
+        echo $rendered !== '' ? $rendered : '<div class="msg-empty">No messages yet.</div>';
+        ?>
+    </div>
+    <form class="thread-compose" autocomplete="off">
+        <input type="text" name="body" placeholder="Message <?= h($v['name']) ?>…" maxlength="4000" required>
+        <button type="submit" class="btn btn-post">Post</button>
+    </form>
+</section>
+<?php endforeach; ?>
+
+</body>
+</html>

+ 0 - 12
PDQAddOne.php

@@ -1,12 +0,0 @@
-<?php 
-include('pdqconnect.inc');
-
-//$sql = "update subK set " . mysqli_real_escape_string($mydb,$column)  . " = '$mySQLDate' where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-$sql = "insert into subK set subKvid=0,subKack='' ,subKstatus='', subKjob='New'";
-$rs=mysqli_query($mydb, $sql) or die(mysqli_error($mydb));
-header( 'Location: ../PDQ.php' ) ;
-
-	
-
-
-

+ 0 - 96
PDQUpdates.php

@@ -1,96 +0,0 @@
-<?php
-include('pdqconnect.inc');
-
-extract($_POST);
-
-if($column=="subKjob" or $column=="subKdesc" or $column=="subKmaterial" ){
-	$parseMe = trim(mysqli_real_escape_string($mydb,$newData));
-	$parseMe = str_replace("<br>", "" ,$parseMe);
-	$sql = "update subK set " . mysqli_real_escape_string($mydb,$column)  ." = '$parseMe' 
-	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-	$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-	if(mysqli_affected_rows($mydb)>0){
-		echo "Success";
-	} else {
-		echo "Failure - $sql";
-	}
-} 
-elseif($column=="subKstatus"){
-	$parseMe = trim(mysqli_real_escape_string($mydb,$newData));
-	$parseMe = str_replace("<br>", "" ,$parseMe);
-	$parseMe = str_replace("&nbsp;", "" ,$parseMe);
-	if($parseMe!=""){
-		if($parseMe=="Shipped"){$newstatus="Received";}
-		if($parseMe=="Received"){$newstatus="Shipped";}	
-		$sql = "update subK set subKstatus = '$newstatus' where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-		$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-	}
-	echo "Success";
-}
-
-elseif($column=="subKack"){
-	$parseMe = str_replace(" ", "" ,trim(mysqli_real_escape_string($mydb,$newData)));
-	$parseMe = str_replace("<br>", "" ,$parseMe);
-	$parseMe = str_replace("&nbsp;", "" ,$parseMe);
-
-	$sql = "update subK set " . mysqli_real_escape_string($mydb,$column)  ." = '". $parseMe. "'
-	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-	$rs=mysqli_query($mydb,$sql) or die($sql);
-	if(mysqli_affected_rows($mydb)>0){
-		echo "Success";
-	} else {
-		echo "Failure - $sql";
-	}
-
-}
-elseif($column=='subKqty'){
-	$parseMe = str_replace(" ", "" ,trim(mysqli_real_escape_string($mydb,$newData)));
-	$parseMe = str_replace("<br>", "" ,$parseMe);
-	$parseMe = str_replace("&nbsp;", "" ,$parseMe);
-
-	$sql = "update subK set " . mysqli_real_escape_string($mydb,$column)  ." = ". $parseMe. " 
-	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-	$rs=mysqli_query($mydb,$sql) or die($sql);
-	if(mysqli_affected_rows($mydb)>0){
-		echo "Success";
-	} else {
-		echo "Failure - $sql";
-	}
-
-}
-elseif($column=='subKdue'){
-	$parseMe = str_replace("/", "-" ,trim($newData));
-	$parseMe = str_replace("<br>", "" ,$parseMe);
-	$parseMe = str_replace("&nbsp;", "" ,$parseMe);
-	$exploded = explode("-", $parseMe);
-	if(count($exploded)==3){
-		$myArray['mon']=$exploded[0];
-		$myArray['mday']=$exploded[1];
-		$myArray['year']=$exploded[2];
-	}
-	elseif(count($exploded)==2){
-		$today=getdate();
-		$myArray['mon']=$exploded[0];
-		$myArray['mday']=$exploded[1];
-		$myArray['year']=$today['year'];	
-	}
-	elseif(count($exploded)==1){
-		$today=getdate();
-		$myArray['mon']=$today['mon'];
-		$myArray['mday']=$exploded[1];
-		$myArray['year']=$today['year'];	
-	}
-	
-	$mySQLDate = $myArray['year'].'-'.$myArray['mon'].'-'.$myArray['mday'];
-	$sql = "update subK set " . mysqli_real_escape_string($mydb,$column)  . " = '$mySQLDate' 
-	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-	$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-	if(mysqli_affected_rows($mydb)>0){
-		echo "Success";
-	} else {
-		echo "Failure - $sql";
-	}
-}
-
-
-

+ 0 - 33
PDQacknowledge.php

@@ -1,33 +0,0 @@
-<?php
-include('pdqconnect.inc');
-
-extract($_POST);
-$subKID = mysqli_real_escape_string($mydb,$subKID);
-
-$parseMe = str_replace(" ", "" ,trim(mysqli_real_escape_string($mydb,$current)));
-$parseMe = str_replace("<br>", "" ,$parseMe);
-$parseMe = str_replace("&nbsp;", "" ,$parseMe);
-
-if($subKID>0){
-	if($column=="subKack"){
-		$sql = "update subK set subKack = ''	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-		$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-		echo "Success";
-	} elseif($column=="subKstatus"){
-		if($parseMe=="Finished"){
-			$sql = "update subK set subKstatus = 'Shipped'	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-			$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-			echo "Success";
-		}
-		if($parseMe=="Shipped"){
-			$sql = "update subK set subKstatus = ''	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-			$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-			echo "Success";
-		}
-		if($parseMe==""){
-			$sql = "update subK set subKstatus = 'Finished'	where subKID = " . mysqli_real_escape_string($mydb,$subKID);
-			$rs=mysqli_query($mydb,$sql) or die(mysqli_error());
-			echo "Success";
-		}
-	}
-} 

+ 0 - 10
PDQmessage.php

@@ -1,10 +0,0 @@
-<?php
-include('pdqconnect.inc');
-
-$sql = "update PDQMessage set message= '".  mysqli_real_escape_string($mydb,$_POST['newmessage'])  . "'  where PDQMid=0";
-$rs=mysqli_query($mydb, $sql) or die(mysqli_error());
-if(mysqli_affected_rows($mydb)>0){
-	echo "Success";
-} else {
-	echo "Failure - $sql";
-}

+ 193 - 0
assets/app.css

@@ -0,0 +1,193 @@
+:root {
+    --c-bg: #fafafa;
+    --c-fg: #222;
+    --c-muted: #777;
+    --c-accent: rgba(49, 13, 84, 0.08);
+    --c-accent-strong: #310d54;
+    --c-warn: #ffc107;
+    --c-ok: #4caf50;
+    --c-info: #2196f3;
+    --c-danger: #e53935;
+    --c-edit: #fff8c4;
+}
+
+* { box-sizing: border-box; }
+
+body {
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+    background: var(--c-bg);
+    color: var(--c-fg);
+    margin: 0;
+    padding: 1rem 2rem;
+}
+
+h1, h2 { margin: 0.25rem 0; }
+
+.topbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: baseline;
+    border-bottom: 2px solid var(--c-accent-strong);
+    padding-bottom: 0.5rem;
+    margin-bottom: 1rem;
+}
+.topbar .who { color: var(--c-muted); font-size: 0.9rem; }
+.sync-time { color: var(--c-muted); font-size: 0.85rem; }
+
+/* ----- jobs table ----- */
+.jobs-wrap { overflow-x: auto; margin-bottom: 2rem; }
+
+table.jobs {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 1.3rem;
+}
+table.jobs th, table.jobs td {
+    padding: 0.5rem 0.75rem;
+    border-bottom: 1px solid #eee;
+    text-align: left;
+}
+table.jobs th {
+    background: var(--c-accent-strong);
+    color: #fff;
+    font-weight: 600;
+    font-size: 1rem;
+    text-transform: uppercase;
+    letter-spacing: 0.05em;
+}
+table.jobs tbody tr:hover { background: var(--c-accent); }
+
+.center { text-align: center; }
+.right  { text-align: right; }
+.smaller { font-size: 0.9rem; }
+.empty   { text-align: center; color: var(--c-muted); padding: 2rem !important; }
+
+tr.status-finished { background: rgba(76, 175, 80, 0.08); }
+tr.status-shipped  { background: rgba(33, 150, 243, 0.08); }
+tr.status-received { background: rgba(0, 0, 0, 0.04); color: var(--c-muted); }
+
+.ack-new {
+    background: var(--c-warn);
+    color: #000;
+    font-weight: 700;
+    border-radius: 4px;
+}
+
+.editable {
+    display: inline-block;
+    min-width: 2rem;
+    padding: 0.1rem 0.35rem;
+    border-radius: 3px;
+    cursor: pointer;
+}
+.editable:hover { background: var(--c-edit); }
+.editable:focus { outline: 2px solid var(--c-info); background: var(--c-edit); }
+.editable.saving { background: orange; }
+.editable.error  { background: var(--c-danger); color: #fff; }
+
+input.inline-edit {
+    font-size: inherit;
+    padding: 0.15rem 0.35rem;
+    border: 1px solid var(--c-info);
+    border-radius: 3px;
+    width: 100%;
+    min-width: 4rem;
+}
+
+.actions { white-space: nowrap; }
+
+.btn {
+    font-size: 0.9rem;
+    padding: 0.35rem 0.7rem;
+    border: 0;
+    border-radius: 4px;
+    cursor: pointer;
+    color: #fff;
+    font-weight: 600;
+    margin: 0 0.15rem;
+}
+.btn-ack    { background: var(--c-warn); color: #000; }
+.btn-finish { background: var(--c-ok); }
+.btn-ship   { background: var(--c-info); }
+.btn-receive{ background: var(--c-accent-strong); }
+.btn-add    { background: var(--c-accent-strong); padding: 0.5rem 1.25rem; font-size: 1rem; }
+.btn-post   { background: var(--c-accent-strong); padding: 0.5rem 1rem; font-size: 1rem; }
+.btn:hover  { opacity: 0.9; }
+.received   { color: var(--c-muted); font-style: italic; }
+
+/* ----- messages ----- */
+.thread {
+    border: 1px solid #ddd;
+    border-radius: 6px;
+    margin-bottom: 1.5rem;
+    background: #fff;
+}
+.thread h2 {
+    margin: 0;
+    padding: 0.5rem 0.75rem;
+    background: var(--c-accent);
+    border-bottom: 1px solid #ddd;
+    font-size: 1rem;
+}
+.thread-list {
+    max-height: 280px;
+    overflow-y: auto;
+    padding: 0.5rem 0.75rem;
+    display: flex;
+    flex-direction: column;
+    gap: 0.5rem;
+}
+.msg {
+    padding: 0.4rem 0.6rem;
+    border-radius: 5px;
+    max-width: 75%;
+    background: #f1f1f1;
+    align-self: flex-start;
+}
+.msg-icg {
+    background: var(--c-accent);
+    align-self: flex-end;
+}
+.msg-author {
+    font-weight: 700;
+    margin-right: 0.5rem;
+}
+.msg-time {
+    color: var(--c-muted);
+    font-size: 0.75rem;
+}
+.msg-body {
+    margin-top: 0.15rem;
+    white-space: pre-wrap;
+    word-wrap: break-word;
+}
+.msg-empty {
+    color: var(--c-muted);
+    font-style: italic;
+    padding: 0.5rem;
+}
+
+.thread-compose {
+    display: flex;
+    gap: 0.5rem;
+    padding: 0.5rem 0.75rem;
+    border-top: 1px solid #ddd;
+    background: #fafafa;
+}
+.thread-compose input[type="text"] {
+    flex: 1;
+    font-size: 1rem;
+    padding: 0.5rem 0.75rem;
+    border: 1px solid #ccc;
+    border-radius: 4px;
+}
+.thread-compose input[type="text"]:focus {
+    outline: 2px solid var(--c-info);
+}
+
+.add-row {
+    margin: 0.5rem 0 1rem;
+    display: flex;
+    gap: 0.5rem;
+    align-items: center;
+}

+ 253 - 0
assets/app.js

@@ -0,0 +1,253 @@
+// Front-end for PDQ / vendor pages.
+//
+// Globals provided by the page via <script> before this file:
+//   PDQ.actor      'ICG' or vendor slug
+//   PDQ.audience   'ICG' or 'vendor'
+//   PDQ.vendors    [{slug, name}, ...]   (ICG view only; vendor view: just its own)
+//   PDQ.pollMs     ms between auto-refresh
+
+(function () {
+  if (typeof window.PDQ === 'undefined') window.PDQ = {};
+  const PDQ = window.PDQ;
+
+  const $ = (sel, root = document) => root.querySelector(sel);
+  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
+
+  // Adds actor/vendor params to AJAX calls so the server can identify us.
+  function authParams() {
+    if (PDQ.audience === 'ICG') return { actor: 'ICG' };
+    return { v: PDQ.actor };
+  }
+
+  function postForm(url, data) {
+    const body = new URLSearchParams({ ...authParams(), ...data });
+    return fetch(url, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+      body,
+    });
+  }
+
+  function get(url, params = {}) {
+    const u = new URL(url, window.location.href);
+    Object.entries({ ...authParams(), ...params })
+      .forEach(([k, v]) => u.searchParams.set(k, v));
+    return fetch(u.toString());
+  }
+
+  // -------- jobs table --------
+
+  function reloadTable() {
+    return get('bin/jobs_table.php')
+      .then(r => r.text())
+      .then(html => {
+        $('#jobs-table').innerHTML = html;
+        wireTable();
+        stampSyncTime();
+      });
+  }
+
+  function stampSyncTime() {
+    const d = new Date();
+    const el = $('#sync-time');
+    if (!el) return;
+    el.textContent = 'Last sync: ' + d.toLocaleTimeString();
+  }
+
+  function wireTable() {
+    $$('#jobs-table .btn[data-action]').forEach(btn => {
+      btn.addEventListener('click', onAction);
+    });
+    $$('#jobs-table .editable').forEach(el => {
+      el.addEventListener('dblclick', beginEdit);
+      el.addEventListener('keydown', e => {
+        if (e.key === 'Enter') { e.preventDefault(); beginEdit.call(el, e); }
+      });
+    });
+  }
+
+  function onAction(e) {
+    const btn = e.currentTarget;
+    const row = btn.closest('tr');
+    const jobId = row.dataset.jobId;
+    const action = btn.dataset.action;
+    btn.disabled = true;
+    postForm('bin/jobs_update.php', { job_id: jobId, action })
+      .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
+      .then(res => {
+        if (!res.ok || res.body.trim() !== 'Success') {
+          alert('Update failed: ' + res.body);
+        }
+        return reloadTable();
+      })
+      .catch(err => alert('Network error: ' + err.message));
+  }
+
+  function beginEdit(e) {
+    const span = this;
+    if (span.querySelector('input')) return;
+    const col = span.dataset.column;
+    const original = (span.dataset.raw !== undefined) ? span.dataset.raw : span.textContent.trim();
+    const initial = original === '—' ? '' : original;
+
+    const input = document.createElement('input');
+    input.type = 'text';
+    input.className = 'inline-edit';
+    input.value = initial;
+    span.innerHTML = '';
+    span.appendChild(input);
+    input.focus();
+    input.select();
+
+    let done = false;
+    const commit = (save) => {
+      if (done) return;
+      done = true;
+      const val = input.value;
+      if (!save || val === initial) {
+        renderValue(span, original, col);
+        return;
+      }
+      span.classList.add('saving');
+      const row = span.closest('tr');
+      postForm('bin/jobs_update.php', {
+        job_id: row.dataset.jobId,
+        column: col,
+        value: val,
+      })
+        .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
+        .then(res => {
+          span.classList.remove('saving');
+          if (!res.ok || res.body.trim() !== 'Success') {
+            span.classList.add('error');
+            alert('Save failed: ' + res.body);
+            renderValue(span, original, col);
+          } else {
+            return reloadTable();
+          }
+        })
+        .catch(err => {
+          span.classList.remove('saving');
+          span.classList.add('error');
+          alert('Network error: ' + err.message);
+          renderValue(span, original, col);
+        });
+    };
+
+    input.addEventListener('blur', () => commit(true));
+    input.addEventListener('keydown', ev => {
+      if (ev.key === 'Enter')   { ev.preventDefault(); input.blur(); }
+      if (ev.key === 'Escape')  { ev.preventDefault(); commit(false); }
+    });
+  }
+
+  function renderValue(span, val, col) {
+    if (col === 'due_date') span.dataset.raw = val;
+    span.textContent = (val === '' || val === null) ? '—' : val;
+  }
+
+  // -------- messages --------
+
+  function reloadAllThreads() {
+    return Promise.all($$('.thread').map(reloadThread));
+  }
+
+  function reloadThread(thread) {
+    const slug = thread.dataset.vendor;
+    const list = thread.querySelector('.thread-list');
+    const since = list.dataset.maxId || 0;
+    return get('bin/messages_list.php', { vendor: slug, since })
+      .then(r => r.text().then(html => ({
+        html,
+        maxId: r.headers.get('X-Max-Id') || since,
+      })))
+      .then(({ html, maxId }) => {
+        if (html.trim()) {
+          if (since === '0' || since === 0) list.innerHTML = '';
+          list.insertAdjacentHTML('beforeend', html);
+          list.scrollTop = list.scrollHeight;
+        }
+        list.dataset.maxId = maxId;
+        const empty = list.querySelector('.msg-empty');
+        if (empty && list.querySelector('.msg')) empty.remove();
+      });
+  }
+
+  function wireCompose() {
+    $$('.thread').forEach(thread => {
+      const form = thread.querySelector('.thread-compose');
+      if (!form) return;
+      form.addEventListener('submit', e => {
+        e.preventDefault();
+        const input = form.querySelector('input[name=body]');
+        const body = input.value.trim();
+        if (!body) return;
+        const slug = thread.dataset.vendor;
+        input.disabled = true;
+        postForm('bin/messages_post.php', { vendor: slug, body })
+          .then(r => r.text().then(t => ({ ok: r.ok, body: t, maxId: r.headers.get('X-Msg-Id') })))
+          .then(res => {
+            input.disabled = false;
+            if (!res.ok) { alert('Post failed: ' + res.body); return; }
+            input.value = '';
+            input.focus();
+            const list = thread.querySelector('.thread-list');
+            const empty = list.querySelector('.msg-empty');
+            if (empty) empty.remove();
+            list.insertAdjacentHTML('beforeend', res.body);
+            list.dataset.maxId = res.maxId || list.dataset.maxId;
+            list.scrollTop = list.scrollHeight;
+          })
+          .catch(err => {
+            input.disabled = false;
+            alert('Network error: ' + err.message);
+          });
+      });
+    });
+  }
+
+  // -------- add job (ICG only) --------
+
+  function wireAddJob() {
+    const btn = $('#add-job');
+    if (!btn) return;
+    btn.addEventListener('click', () => {
+      const select = $('#add-job-vendor');
+      const vendor = select ? select.value : 'bill';
+      postForm('bin/jobs_add.php', { vendor, ajax: '1' })
+        .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
+        .then(res => {
+          if (!res.ok || res.body.trim() !== 'Success') {
+            alert('Add failed: ' + res.body);
+            return;
+          }
+          return reloadTable();
+        })
+        .catch(err => alert('Network error: ' + err.message));
+    });
+  }
+
+  // -------- polling --------
+
+  function isUserBusy() {
+    const a = document.activeElement;
+    if (!a) return false;
+    const tag = a.tagName;
+    return tag === 'INPUT' || tag === 'TEXTAREA' || a.isContentEditable;
+  }
+
+  function tick() {
+    if (isUserBusy()) return;
+    reloadTable();
+    reloadAllThreads();
+  }
+
+  document.addEventListener('DOMContentLoaded', () => {
+    wireAddJob();
+    wireCompose();
+    reloadTable();
+    reloadAllThreads();
+    const interval = PDQ.pollMs || 60000;
+    setInterval(tick, interval);
+  });
+})();

+ 0 - 29
billstable.php

@@ -1,29 +0,0 @@
-<?php
-include_once('pdqconnect.inc');
-
-$sql = "select subK.*, date_format(subKdue,'%c-%e') as dueDate from subK 
-		where (subKstatus != 'Received') or ( subKLastUpdate > DATE_SUB(CURDATE(), INTERVAL 4 DAY))
-		order by subKstatus asc, subKack desc, subKdue asc, subKjob";
-
-$rs=mysqli_query($mydb,$sql) or die(mysqli_error() . "<br>". $sql);
-echo "<table border=0>";
-echo "<tr><th>Job</th><th>Material</th><th>Description</th><th>Qty</th><th>Due Date</th><th>New</th><th>Status</th></tr>\n";
-while($row=mysqli_fetch_array($rs)){
-	if(strlen($row['subKjob'])>9){
-		$fontClass='smaller';
-	} else {
-		$fontClass='';
-	}
-//	$Vname = $row['Vname'];
-	$Qty = number_format($row['subKqty'],0);
-	echo "<tr><td data-subKID='{$row['subKID']}' data-column='subKjob' class='$fontClass'>{$row['subKjob']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKmaterial' class=''>{$row['subKmaterial']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKdesc' class=' center'>{$row['subKdesc']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKqty' class=' right'>$Qty</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKdue' class=' center'>{$row['dueDate']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKack' class='updatableDiv center' >{$row['subKack']}</td>";
-	echo "<td data-subKID='{$row['subKID']}' data-column='subKstatus' class='updatableDiv center' >{$row['subKstatus']}</td></tr>\n";
-}
-echo "<caption id='tableCap'>Schedule</caption></table>";
-
-?>

+ 29 - 0
bin/jobs_add.php

@@ -0,0 +1,29 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+
+[$actor, ] = resolve_request_actor();
+if ($actor !== 'ICG') {
+    http_response_code(403);
+    echo 'Only ICG can add jobs';
+    exit;
+}
+
+$vendor_slug = $_POST['vendor'] ?? 'bill';
+$vendor = find_vendor_by_slug($vendor_slug);
+if (!$vendor) {
+    http_response_code(400);
+    echo 'Unknown vendor';
+    exit;
+}
+
+$pdo = db();
+$stmt = $pdo->prepare(
+    "INSERT INTO jobs(vendor_id, job, ack, status) VALUES(?, 'New', 'new', '')"
+);
+$stmt->execute([$vendor['id']]);
+
+if (!empty($_POST['ajax'])) {
+    echo 'Success';
+} else {
+    header('Location: ../PDQ.php');
+}

+ 9 - 0
bin/jobs_table.php

@@ -0,0 +1,9 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/render.php';
+
+[$actor, $vendor_id] = resolve_request_actor();
+$audience = $actor === 'ICG' ? 'ICG' : 'vendor';
+
+header('Content-Type: text/html; charset=utf-8');
+echo render_jobs_table($audience, $vendor_id);

+ 88 - 0
bin/jobs_update.php

@@ -0,0 +1,88 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/jobs.php';
+
+[$actor, $vendor_id] = resolve_request_actor();
+$pdo = db();
+
+$job_id = (int) ($_POST['job_id'] ?? 0);
+$action = $_POST['action'] ?? '';
+$column = $_POST['column'] ?? '';
+$value  = $_POST['value']  ?? '';
+
+if ($job_id <= 0) {
+    http_response_code(400);
+    echo 'Bad job_id';
+    return;
+}
+
+$stmt = $pdo->prepare('SELECT * FROM jobs WHERE id = ?');
+$stmt->execute([$job_id]);
+$job = $stmt->fetch();
+if (!$job) {
+    http_response_code(404);
+    echo 'Job not found';
+    return;
+}
+
+// Vendor-side requests are scoped to their own jobs.
+if ($actor !== 'ICG' && (int) $job['vendor_id'] !== $vendor_id) {
+    http_response_code(403);
+    echo 'Wrong vendor';
+    return;
+}
+
+// --- Button-driven state transitions ---
+if ($action !== '') {
+    $allowed = [
+        'acknowledge'    => ['ack',    '',          ['vendor']],
+        'mark_finished'  => ['status', 'Finished',  ['vendor']],
+        'mark_shipped'   => ['status', 'Shipped',   ['vendor']],
+        'mark_received'  => ['status', 'Received',  ['ICG']],
+    ];
+    if (!isset($allowed[$action])) {
+        http_response_code(400);
+        echo 'Unknown action';
+        return;
+    }
+    [$col, $new, $roles] = $allowed[$action];
+    $role = $actor === 'ICG' ? 'ICG' : 'vendor';
+    if (!in_array($role, $roles, true)) {
+        http_response_code(403);
+        echo 'Action not allowed for this role';
+        return;
+    }
+    apply_job_change($job, $col, $new, $actor);
+    echo 'Success';
+    return;
+}
+
+// --- Field edits (ICG only) ---
+if ($actor !== 'ICG') {
+    http_response_code(403);
+    echo 'Edits restricted to ICG';
+    return;
+}
+
+$editable = ['job', 'material', 'description', 'qty', 'due_date'];
+if (!in_array($column, $editable, true)) {
+    http_response_code(400);
+    echo 'Unknown column';
+    return;
+}
+
+$value = trim($value);
+
+if ($column === 'qty') {
+    if ($value === '' || !is_numeric($value)) {
+        http_response_code(400);
+        echo 'Qty must be a number';
+        return;
+    }
+    $value = (string) (int) $value;
+} elseif ($column === 'due_date') {
+    $value = parse_due_date($value);
+}
+
+apply_job_change($job, $column, $value, $actor);
+echo 'Success';

+ 20 - 0
bin/messages_list.php

@@ -0,0 +1,20 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/render.php';
+
+[$actor, $vendor_id] = resolve_request_actor();
+
+// ICG callers can request any vendor's thread via ?vendor=<slug>; vendor
+// callers are pinned to their own thread by resolve_request_actor().
+if ($actor === 'ICG') {
+    $slug = $_GET['vendor'] ?? '';
+    $v = find_vendor_by_slug($slug);
+    if (!$v) { http_response_code(400); echo 'Bad vendor'; exit; }
+    $vendor_id = (int) $v['id'];
+}
+
+$since = isset($_GET['since']) ? (int) $_GET['since'] : null;
+
+header('Content-Type: text/html; charset=utf-8');
+header('X-Max-Id: ' . max_message_id($vendor_id));
+echo render_messages($vendor_id, $since);

+ 33 - 0
bin/messages_post.php

@@ -0,0 +1,33 @@
+<?php
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/render.php';
+
+[$actor, $vendor_id] = resolve_request_actor();
+
+if ($actor === 'ICG') {
+    $slug = $_POST['vendor'] ?? '';
+    $v = find_vendor_by_slug($slug);
+    if (!$v) { http_response_code(400); echo 'Bad vendor'; exit; }
+    $vendor_id = (int) $v['id'];
+}
+
+$body = trim((string) ($_POST['body'] ?? ''));
+if ($body === '') {
+    http_response_code(400);
+    echo 'Empty message';
+    exit;
+}
+if (strlen($body) > 4000) {
+    $body = substr($body, 0, 4000);
+}
+
+$pdo = db();
+$stmt = $pdo->prepare(
+    'INSERT INTO messages(vendor_id, author, body) VALUES(?, ?, ?)'
+);
+$stmt->execute([$vendor_id, $actor, $body]);
+$new_id = (int) $pdo->lastInsertId();
+
+header('Content-Type: text/html; charset=utf-8');
+header('X-Msg-Id: ' . $new_id);
+echo render_messages($vendor_id, $new_id - 1);

+ 89 - 0
lib/db.php

@@ -0,0 +1,89 @@
+<?php
+// PDO SQLite bootstrap + idempotent schema migrations.
+// Call db() from any endpoint to get the connection.
+
+function db(): PDO {
+    static $pdo = null;
+    if ($pdo !== null) return $pdo;
+
+    $dir = __DIR__ . '/../data';
+    if (!is_dir($dir)) {
+        mkdir($dir, 0775, true);
+    }
+    $path = $dir . '/pdq.sqlite';
+
+    $pdo = new PDO('sqlite:' . $path);
+    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+    $pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+    $pdo->exec('PRAGMA foreign_keys = ON');
+    $pdo->exec('PRAGMA journal_mode = WAL');
+
+    db_migrate($pdo);
+    return $pdo;
+}
+
+function db_migrate(PDO $pdo): void {
+    $version = (int) $pdo->query('PRAGMA user_version')->fetchColumn();
+
+    if ($version < 1) {
+        $pdo->exec("
+            CREATE TABLE vendors (
+                id     INTEGER PRIMARY KEY,
+                slug   TEXT NOT NULL UNIQUE,
+                name   TEXT NOT NULL,
+                active INTEGER NOT NULL DEFAULT 1
+            );
+
+            CREATE TABLE jobs (
+                id          INTEGER PRIMARY KEY,
+                vendor_id   INTEGER NOT NULL REFERENCES vendors(id),
+                job         TEXT NOT NULL DEFAULT 'New',
+                material    TEXT NOT NULL DEFAULT '',
+                description TEXT NOT NULL DEFAULT '',
+                qty         INTEGER NOT NULL DEFAULT 0,
+                due_date    TEXT,
+                ack         TEXT NOT NULL DEFAULT 'new',
+                status      TEXT NOT NULL DEFAULT '',
+                created_at  TEXT NOT NULL DEFAULT (datetime('now')),
+                updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
+            );
+            CREATE INDEX jobs_vendor_status ON jobs(vendor_id, status);
+
+            CREATE TABLE job_history (
+                id         INTEGER PRIMARY KEY,
+                job_id     INTEGER NOT NULL REFERENCES jobs(id),
+                field      TEXT NOT NULL,
+                old_value  TEXT,
+                new_value  TEXT,
+                actor      TEXT NOT NULL,
+                changed_at TEXT NOT NULL DEFAULT (datetime('now'))
+            );
+            CREATE INDEX job_history_job ON job_history(job_id, changed_at);
+
+            CREATE TABLE messages (
+                id        INTEGER PRIMARY KEY,
+                vendor_id INTEGER NOT NULL REFERENCES vendors(id),
+                author    TEXT NOT NULL,
+                body      TEXT NOT NULL,
+                posted_at TEXT NOT NULL DEFAULT (datetime('now'))
+            );
+            CREATE INDEX messages_vendor_time ON messages(vendor_id, posted_at);
+        ");
+
+        $pdo->prepare('INSERT INTO vendors(slug, name) VALUES(?, ?)')
+            ->execute(['bill', 'Bill']);
+
+        $pdo->exec('PRAGMA user_version = 1');
+    }
+}
+
+function find_vendor_by_slug(string $slug): ?array {
+    $stmt = db()->prepare('SELECT * FROM vendors WHERE slug = ? AND active = 1');
+    $stmt->execute([$slug]);
+    $row = $stmt->fetch();
+    return $row ?: null;
+}
+
+function all_vendors(): array {
+    return db()->query('SELECT * FROM vendors WHERE active = 1 ORDER BY name')->fetchAll();
+}

+ 47 - 0
lib/identity.php

@@ -0,0 +1,47 @@
+<?php
+require_once __DIR__ . '/db.php';
+
+// Resolve the current actor (who is making this request).
+// - ICG pages call current_actor('ICG').
+// - Vendor pages call current_actor_from_vendor($_GET['v'] ?? '').
+// - AJAX endpoints read both an explicit ?actor=ICG flag (set by PDQ.php) and a
+//   ?v=<slug> flag (set by vendor.php). The actor is whichever side called us.
+// This matches the existing trust model: access to PDQ.php vs vendor.php IS the
+// only access control we have. Don't trust the actor value with anything you
+// wouldn't already trust the URL with.
+
+function current_actor(string $expected): string {
+    if ($expected !== 'ICG') {
+        throw new InvalidArgumentException("current_actor expects 'ICG'");
+    }
+    return 'ICG';
+}
+
+function current_actor_from_vendor(string $slug): array {
+    $v = find_vendor_by_slug($slug);
+    if (!$v) {
+        http_response_code(404);
+        echo "Unknown vendor: " . htmlspecialchars($slug);
+        exit;
+    }
+    return $v;
+}
+
+// Resolve the actor for an AJAX endpoint based on POST/GET fields:
+//   actor=ICG               -> 'ICG'
+//   v=<vendor-slug>         -> vendor slug
+// Returns [actor_label, vendor_id_or_null].
+function resolve_request_actor(): array {
+    $params = $_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET;
+
+    if (($params['actor'] ?? '') === 'ICG') {
+        return ['ICG', null];
+    }
+    if (!empty($params['v'])) {
+        $v = find_vendor_by_slug($params['v']);
+        if ($v) return [$v['slug'], (int) $v['id']];
+    }
+    http_response_code(400);
+    echo 'No actor specified';
+    exit;
+}

+ 49 - 0
lib/jobs.php

@@ -0,0 +1,49 @@
+<?php
+require_once __DIR__ . '/db.php';
+
+// Apply a single-field change to a job. If the column is audited
+// (status or ack) and the value changed, append a job_history row.
+function apply_job_change(array $job, string $col, $new, string $actor): void {
+    $pdo = db();
+    $old = $job[$col];
+    if ((string) $old === (string) $new) return;
+
+    $pdo->beginTransaction();
+    try {
+        $stmt = $pdo->prepare("UPDATE jobs SET $col = ?, updated_at = datetime('now') WHERE id = ?");
+        $stmt->execute([$new, $job['id']]);
+
+        if (in_array($col, ['status', 'ack'], true)) {
+            $hist = $pdo->prepare(
+                'INSERT INTO job_history(job_id, field, old_value, new_value, actor)
+                 VALUES (?, ?, ?, ?, ?)'
+            );
+            $hist->execute([$job['id'], $col, (string) $old, (string) $new, $actor]);
+        }
+        $pdo->commit();
+    } catch (Throwable $e) {
+        $pdo->rollBack();
+        throw $e;
+    }
+}
+
+// Parse the same M-D / M-D-Y forms the original PDQUpdates.php accepted.
+// Returns ISO 'YYYY-MM-DD' or null (which clears the date).
+function parse_due_date(string $raw): ?string {
+    $raw = trim(str_replace('/', '-', $raw));
+    if ($raw === '') return null;
+    $parts = explode('-', $raw);
+    $today = getdate();
+    if (count($parts) === 3) {
+        [$m, $d, $y] = $parts;
+    } elseif (count($parts) === 2) {
+        [$m, $d] = $parts;
+        $y = $today['year'];
+    } else {
+        return null;
+    }
+    $m = (int) $m; $d = (int) $d; $y = (int) $y;
+    if ($y < 100) $y += 2000;
+    if (!checkdate($m, $d, $y)) return null;
+    return sprintf('%04d-%02d-%02d', $y, $m, $d);
+}

+ 153 - 0
lib/render.php

@@ -0,0 +1,153 @@
+<?php
+require_once __DIR__ . '/db.php';
+
+function h(?string $s): string {
+    return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
+}
+
+// Render the job table for a given audience.
+// $audience: 'ICG' or 'vendor'
+// $vendor_id: required when $audience === 'vendor'
+function render_jobs_table(string $audience, ?int $vendor_id = null): string {
+    $pdo = db();
+
+    // Filter matches the original: open jobs OR anything touched in the last 4 days.
+    $where = "(j.status != 'Received') OR (j.updated_at > datetime('now', '-4 days'))";
+    $params = [];
+    if ($audience === 'vendor') {
+        $where = "j.vendor_id = ? AND ($where)";
+        $params[] = $vendor_id;
+    }
+
+    $sql = "
+        SELECT j.*, v.slug AS vendor_slug, v.name AS vendor_name,
+               CASE WHEN j.due_date IS NULL THEN ''
+                    ELSE CAST(CAST(strftime('%m', j.due_date) AS INTEGER) AS TEXT) || '-' ||
+                         CAST(CAST(strftime('%d', j.due_date) AS INTEGER) AS TEXT)
+               END AS due_short
+        FROM jobs j
+        JOIN vendors v ON v.id = j.vendor_id
+        WHERE $where
+        ORDER BY (j.status = '') DESC,
+                 (j.status = 'Finished') DESC,
+                 (j.status = 'Shipped') DESC,
+                 (j.ack = 'new') DESC,
+                 j.due_date IS NULL,
+                 j.due_date ASC,
+                 j.job
+    ";
+
+    $stmt = $pdo->prepare($sql);
+    $stmt->execute($params);
+    $rows = $stmt->fetchAll();
+
+    ob_start();
+    ?>
+    <table class="jobs">
+        <thead>
+        <tr>
+            <th>Job</th>
+            <th>Material</th>
+            <th>Description</th>
+            <th class="right">Qty</th>
+            <th class="center">Due</th>
+            <?php if ($audience === 'ICG'): ?><th>Vendor</th><?php endif; ?>
+            <th class="center">Ack</th>
+            <th class="center">Status</th>
+            <th class="center">Action</th>
+        </tr>
+        </thead>
+        <tbody>
+        <?php if (!$rows): ?>
+            <tr><td colspan="<?= $audience === 'ICG' ? 9 : 8 ?>" class="empty">No active jobs.</td></tr>
+        <?php endif; ?>
+        <?php foreach ($rows as $r):
+            $editable = ($audience === 'ICG');
+            $jobClass = strlen($r['job']) > 9 ? 'smaller' : '';
+            $statusClass = $r['status'] !== '' ? 'status-' . strtolower($r['status']) : '';
+            $ackClass = $r['ack'] === 'new' ? 'ack-new' : '';
+        ?>
+            <tr data-job-id="<?= (int) $r['id'] ?>" class="<?= h($statusClass) ?>">
+                <td class="<?= h($jobClass) ?>"><?= render_field($r, 'job', $editable) ?></td>
+                <td><?= render_field($r, 'material', $editable) ?></td>
+                <td class="center"><?= render_field($r, 'description', $editable) ?></td>
+                <td class="right"><?= render_field($r, 'qty', $editable) ?></td>
+                <td class="center"><?= render_due($r, $editable) ?></td>
+                <?php if ($audience === 'ICG'): ?><td><?= h($r['vendor_name']) ?></td><?php endif; ?>
+                <td class="center <?= h($ackClass) ?>"><?= $r['ack'] === 'new' ? 'NEW' : '' ?></td>
+                <td class="center"><?= h($r['status']) ?></td>
+                <td class="center actions"><?= render_actions($r, $audience) ?></td>
+            </tr>
+        <?php endforeach; ?>
+        </tbody>
+    </table>
+    <?php
+    return ob_get_clean();
+}
+
+function render_field(array $r, string $col, bool $editable): string {
+    $val = (string) ($r[$col] ?? '');
+    if (!$editable) {
+        return h($val);
+    }
+    return '<span class="editable" data-column="' . h($col) . '" tabindex="0">'
+        . ($val === '' ? '&mdash;' : h($val))
+        . '</span>';
+}
+
+function render_due(array $r, bool $editable): string {
+    $display = $r['due_short'] !== '' ? h($r['due_short']) : '&mdash;';
+    if (!$editable) return $display;
+    return '<span class="editable" data-column="due_date" data-raw="' . h($r['due_date']) . '" tabindex="0">'
+        . $display . '</span>';
+}
+
+function render_actions(array $r, string $audience): string {
+    $btns = [];
+    if ($audience === 'vendor') {
+        if ($r['ack'] === 'new') {
+            $btns[] = '<button class="btn btn-ack" data-action="acknowledge">Acknowledge</button>';
+        }
+        if ($r['status'] === '') {
+            $btns[] = '<button class="btn btn-finish" data-action="mark_finished">Mark Finished</button>';
+        } elseif ($r['status'] === 'Finished') {
+            $btns[] = '<button class="btn btn-ship" data-action="mark_shipped">Mark Shipped</button>';
+        }
+    } else { // ICG
+        if ($r['status'] === 'Shipped') {
+            $btns[] = '<button class="btn btn-receive" data-action="mark_received">Mark Received</button>';
+        }
+        if ($r['status'] === 'Received') {
+            $btns[] = '<span class="received">Received</span>';
+        }
+    }
+    return implode(' ', $btns);
+}
+
+function render_messages(int $vendor_id, ?int $since_id = null): string {
+    $pdo = db();
+    if ($since_id !== null) {
+        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? AND id > ? ORDER BY id ASC');
+        $stmt->execute([$vendor_id, $since_id]);
+    } else {
+        $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? ORDER BY id ASC');
+        $stmt->execute([$vendor_id]);
+    }
+    $rows = $stmt->fetchAll();
+    ob_start();
+    foreach ($rows as $m) {
+        $klass = $m['author'] === 'ICG' ? 'msg msg-icg' : 'msg msg-vendor';
+        echo '<div class="' . $klass . '" data-msg-id="' . (int)$m['id'] . '">';
+        echo '<span class="msg-author">' . h($m['author']) . '</span>';
+        echo '<span class="msg-time">' . h($m['posted_at']) . '</span>';
+        echo '<div class="msg-body">' . nl2br(h($m['body'])) . '</div>';
+        echo '</div>';
+    }
+    return ob_get_clean();
+}
+
+function max_message_id(int $vendor_id): int {
+    $stmt = db()->prepare('SELECT COALESCE(MAX(id), 0) FROM messages WHERE vendor_id = ?');
+    $stmt->execute([$vendor_id]);
+    return (int) $stmt->fetchColumn();
+}

+ 51 - 0
vendor.php

@@ -0,0 +1,51 @@
+<?php
+require_once __DIR__ . '/lib/identity.php';
+require_once __DIR__ . '/lib/render.php';
+
+$slug = $_GET['v'] ?? '';
+$vendor = current_actor_from_vendor($slug);
+$vid = (int) $vendor['id'];
+$maxId = max_message_id($vid);
+?><!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title><?= h($vendor['name']) ?> &mdash; Schedule</title>
+<link rel="stylesheet" href="assets/app.css">
+<script>
+window.PDQ = {
+    actor: <?= json_encode($vendor['slug']) ?>,
+    audience: 'vendor',
+    vendors: [<?= json_encode(['slug' => $vendor['slug'], 'name' => $vendor['name']]) ?>],
+    pollMs: 60000
+};
+</script>
+<script src="assets/app.js" defer></script>
+</head>
+<body>
+
+<div class="topbar">
+    <h1><?= h($vendor['name']) ?>'s Schedule</h1>
+    <span class="who">Signed in as <strong><?= h($vendor['name']) ?></strong> &middot; <span id="sync-time">Loading…</span></span>
+</div>
+
+<div class="jobs-wrap">
+    <div id="jobs-table"><?= render_jobs_table('vendor', $vid) ?></div>
+</div>
+
+<section class="thread" data-vendor="<?= h($vendor['slug']) ?>">
+    <h2>Messages with ICG</h2>
+    <div class="thread-list" data-max-id="<?= $maxId ?>">
+        <?php
+        $rendered = render_messages($vid);
+        echo $rendered !== '' ? $rendered : '<div class="msg-empty">No messages yet.</div>';
+        ?>
+    </div>
+    <form class="thread-compose" autocomplete="off">
+        <input type="text" name="body" placeholder="Message ICG…" maxlength="4000" required>
+        <button type="submit" class="btn btn-post">Post</button>
+    </form>
+</section>
+
+</body>
+</html>