contracts-admin.php 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636
  1. <?php
  2. /**
  3. * Contracts Admin MVP
  4. * Single-file admin + JSON API for listing, editing, and emailing contract links.
  5. */
  6. declare(strict_types=1);
  7. date_default_timezone_set('Australia/Hobart');
  8. session_start();
  9. if ($_SERVER["REQUEST_METHOD"] === "POST") {
  10. // allow the public "mark_signed" webhook to skip CSRF (it uses a shared secret)
  11. $isMarkSigned = (($_POST['action'] ?? '') === 'mark_signed');
  12. if (!$isMarkSigned) {
  13. $ok = isset($_POST["csrf"], $_SESSION["csrf"]) && hash_equals($_SESSION["csrf"], $_POST["csrf"]);
  14. if (!$ok) {
  15. http_response_code(403);
  16. exit("Invalid CSRF token");
  17. }
  18. }
  19. }
  20. if (empty($_SESSION["csrf"])) {
  21. $_SESSION["csrf"] = bin2hex(random_bytes(32));
  22. }
  23. $csrf = htmlspecialchars($_SESSION["csrf"] ?? "", ENT_QUOTES, "UTF-8");
  24. // Load cfg array
  25. $cfg = @include __DIR__ . "/../config.php";
  26. $cfg = is_array($cfg) ? $cfg : [];
  27. // PHPMailer (your internal includes)
  28. use PHPMailer\PHPMailer\PHPMailer;
  29. use PHPMailer\PHPMailer\SMTP;
  30. use PHPMailer\PHPMailer\Exception;
  31. require_once "../../internal/phpmailer/src/Exception.php";
  32. require_once "../../internal/phpmailer/src/PHPMailer.php";
  33. require_once "../../internal/phpmailer/src/SMTP.php";
  34. define('BASE_URL', 'https://modulosdesign.com.au/contracts'); // no /contracts-admin here
  35. /* -------------------------------------------------------------------------- */
  36. /* CONFIGURATION */
  37. /* -------------------------------------------------------------------------- */
  38. /**
  39. * If you already have a config.php with DB and SMTP, include it here.
  40. * You can also define constants below to override.
  41. */
  42. $external_config = '../config.php';
  43. if (file_exists($external_config)) {
  44. require_once $external_config;
  45. }
  46. // Contracts directory. Update to your absolute path in production.
  47. if (!defined('CONTRACTS_DIR')) {
  48. define('CONTRACTS_DIR', '../contracts');
  49. }
  50. // Base URL where the public signing page lives
  51. // Example: https://modulosdesign.com.au/contracts
  52. //if (!defined('BASE_URL')) {
  53. // define('BASE_URL', 'https://modulosdesign.com.au/contracts/contracts-admin');
  54. //}
  55. // Shared secret so the public signing page can mark a contract as "signed"
  56. if (!defined('ADMIN_SHARED_SECRET')) {
  57. define('ADMIN_SHARED_SECRET', 'change-this-secret');
  58. }
  59. // Admin auth. Use HTTP Basic Auth for the MVP.
  60. if (!defined('ADMIN_USER')) define('ADMIN_USER', 'admin');
  61. if (!defined('ADMIN_PASS')) define('ADMIN_PASS', 'changeme');
  62. // Database configuration. If nothing is defined, we auto-fallback to SQLite.
  63. if (!defined('DB_DSN')) define('DB_DSN', 'sqlite:' . __DIR__ . '/contracts.sqlite');
  64. if (!defined('DB_USER')) define('DB_USER', '');
  65. if (!defined('DB_PASS')) define('DB_PASS', '');
  66. // Optional: SMTP configuration if you prefer PHPMailer or similar.
  67. // For the MVP we use mail() by default. If you have PHPMailer autoloaded, we will try to use it.
  68. if (!defined('MAIL_FROM')) define('MAIL_FROM', 'no-reply@modulosdesign.com.au');
  69. if (!defined('MAIL_FROM_NAME')) define('MAIL_FROM_NAME', 'Modulos Design');
  70. // --- LOA (Authorisation) config ---
  71. if (!defined('LOA_DIR')) define('LOA_DIR', '../loa'); // sibling to ../contracts
  72. if (!defined('LOA_BASE_URL')) define('LOA_BASE_URL', 'https://modulosdesign.com.au/contracts'); // where loa.php lives
  73. // IMPORTANT: set this to the SAME secret used in loa.php ($CFG['secret'] / APP_HMAC_SECRET)
  74. if (!defined('LOA_TOKEN_SECRET')) define('LOA_TOKEN_SECRET', 'd1Epy6ryzgLYjLEBlpiHFrgST8JbAjgksjj3hIO5zCK5DChqYpWUdr8jeWR7xEgd');
  75. $tab = $_GET['tab'] ?? 'contracts';
  76. /* -------------------------------------------------------------------------- */
  77. /* HELPER FUNCTIONS */
  78. /* -------------------------------------------------------------------------- */
  79. function require_admin_auth(): void {
  80. if (!isset($_SERVER['PHP_AUTH_USER'])) {
  81. header('WWW-Authenticate: Basic realm="Contracts Admin"');
  82. header('HTTP/1.0 401 Unauthorized');
  83. echo 'Auth required';
  84. exit;
  85. }
  86. if ($_SERVER['PHP_AUTH_USER'] !== ADMIN_USER || ($_SERVER['PHP_AUTH_PW'] ?? '') !== ADMIN_PASS) {
  87. header('WWW-Authenticate: Basic realm="Contracts Admin"');
  88. header('HTTP/1.0 401 Unauthorized');
  89. echo 'Invalid credentials';
  90. exit;
  91. }
  92. }
  93. function json_response(array $payload, int $code = 200): void {
  94. http_response_code($code);
  95. header('Content-Type: application/json; charset=utf-8');
  96. echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
  97. exit;
  98. }
  99. function clientid_from_filename(string $fn): string {
  100. return preg_replace('/\.md$/i', '', basename($fn));
  101. }
  102. function safe_clientid(string $id): string {
  103. // Accept digits and simple ids like "3043" or "client-3043". Adjust if needed.
  104. if (!preg_match('/^[A-Za-z0-9_-]+$/', $id)) {
  105. throw new RuntimeException('Invalid client id');
  106. }
  107. return $id;
  108. }
  109. function contract_path(string $clientid): string {
  110. $found = find_contract_path_by_clientid($clientid);
  111. if ($found) return $found; // existing file in any subfolder
  112. // default location for new files
  113. $id = safe_clientid($clientid);
  114. return rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
  115. }
  116. function ensure_db(): PDO {
  117. static $pdo = null;
  118. if ($pdo instanceof PDO) return $pdo;
  119. $pdo = new PDO(DB_DSN, DB_USER, DB_PASS, [
  120. PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
  121. PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
  122. ]);
  123. // Create table if missing
  124. $pdo->exec("CREATE TABLE IF NOT EXISTS contract_status (
  125. clientid TEXT PRIMARY KEY,
  126. sent INTEGER DEFAULT 0,
  127. sent_at TEXT NULL,
  128. signed INTEGER DEFAULT 0,
  129. signed_at TEXT NULL,
  130. last_email_to TEXT NULL,
  131. pdf_path TEXT NULL,
  132. signer_name TEXT NULL,
  133. signer_ip TEXT NULL
  134. )");
  135. return $pdo;
  136. }
  137. function get_status(string $clientid): array {
  138. $pdo = ensure_db();
  139. $stmt = $pdo->prepare("SELECT * FROM contract_status WHERE clientid = :id");
  140. $stmt->execute([':id' => $clientid]);
  141. $row = $stmt->fetch();
  142. if (!$row) {
  143. return [
  144. 'clientid' => $clientid,
  145. 'sent' => 0,
  146. 'sent_at' => null,
  147. 'signed' => 0,
  148. 'signed_at' => null,
  149. 'last_email_to' => null,
  150. 'pdf_path' => null,
  151. 'signer_name' => null,
  152. 'signer_ip' => null,
  153. ];
  154. }
  155. return $row;
  156. }
  157. function set_sent(string $clientid, string $email): void {
  158. $pdo = ensure_db();
  159. $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, sent, sent_at, last_email_to)
  160. VALUES (:id, 1, :now, :email)
  161. ON CONFLICT(clientid) DO UPDATE SET
  162. sent = 1,
  163. sent_at = :now,
  164. last_email_to = :email");
  165. $stmt->execute([
  166. ':id' => $clientid,
  167. ':now' => date('Y-m-d H:i:s'),
  168. ':email' => $email
  169. ]);
  170. }
  171. function set_signed(string $clientid, ?string $signerName, ?string $signerIp, ?string $pdfPath = null): void {
  172. $pdo = ensure_db();
  173. $stmt = $pdo->prepare("INSERT INTO contract_status (clientid, signed, signed_at, signer_name, signer_ip, pdf_path)
  174. VALUES (:id, 1, :now, :name, :ip, :pdf)
  175. ON CONFLICT(clientid) DO UPDATE SET
  176. signed = 1,
  177. signed_at = :now,
  178. signer_name = COALESCE(:name, signer_name),
  179. signer_ip = COALESCE(:ip, signer_ip),
  180. pdf_path = COALESCE(:pdf, pdf_path)");
  181. $stmt->execute([
  182. ':id' => $clientid,
  183. ':now' => date('Y-m-d H:i:s'),
  184. ':name' => $signerName,
  185. ':ip' => $signerIp,
  186. ':pdf' => $pdfPath
  187. ]);
  188. }
  189. function extract_front_matter_fields(string $file): array {
  190. $out = [];
  191. $txt = @file_get_contents($file);
  192. if (!$txt) return $out;
  193. // Grab the first front matter block
  194. if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
  195. $fm = $m[1];
  196. // Very simple line-based pulls (keeps dependencies out)
  197. // client:
  198. if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)) {
  199. $clientBlock = $block[1];
  200. if (preg_match('/^\s*name\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_name'] = trim($mm[1]);
  201. if (preg_match('/^\s*email\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_email'] = trim($mm[1]);
  202. if (preg_match('/^\s*id\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $clientBlock, $mm)) $out['client_id'] = trim($mm[1]);
  203. }
  204. if (preg_match('/^\s*project\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['project'] = trim($mm[1]);
  205. if (preg_match('/^\s*job\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['job'] = trim($mm[1]);
  206. if (preg_match('/^\s*user\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_user'] = trim($mm[1]);
  207. if (preg_match('/^\s*pass\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_pass'] = trim($mm[1]);
  208. if (preg_match('/^\s*secret\s*:\s*["\']?([^"\'\r\n]+)["\']?/mi', $fm, $mm)) $out['admin_secret'] = trim($mm[1]);
  209. return $out;
  210. }
  211. function url_join(string $base, string $path): string {
  212. return rtrim($base, '/') . '/' . ltrim($path, '/');
  213. }
  214. function contract_public_url(string $clientid): string {
  215. $meta = extract_front_matter_fields(contract_path($clientid));
  216. $secret = $meta['admin_secret'] ?? ($meta['admin']['secret'] ?? '');
  217. if ($secret === '') {
  218. throw new RuntimeException("Missing admin secret for client ID: {$clientid}");
  219. }
  220. $token = hash_hmac('sha256', $clientid, $secret);
  221. $signUrl = url_join(BASE_URL, 'contract.php');
  222. return $signUrl . '?clientid=' . rawurlencode($clientid) . '&token=' . rawurlencode($token);
  223. }
  224. function email_logo_png_cid(PHPMailer $mail, string $dataUrl, string $alt = 'Modulos Design', int $width = 200): string {
  225. if ($dataUrl === '') return '';
  226. // Fast check for the PNG data URL prefix
  227. $prefix = 'data:image/png;base64,';
  228. if (stripos($dataUrl, $prefix) !== 0) return '';
  229. $bin = base64_decode(substr($dataUrl, strlen($prefix)), true);
  230. if ($bin === false) return '';
  231. // Stable CID so replies and forwards keep the reference
  232. $cid = 'logo_' . substr(sha1($bin), 0, 12) . '@modulos';
  233. $mail->addStringEmbeddedImage($bin, $cid, 'logo.png', 'base64', 'image/png');
  234. return '<img src="cid:' . $cid . '" alt="' . htmlspecialchars($alt, ENT_QUOTES, 'UTF-8') . '" width="' . (int)$width . '" style="display:block;border:0;outline:0;text-decoration:none;height:auto;">';
  235. }
  236. function salutationFromName(string $fullName): string {
  237. // Normalize whitespace (incl. NBSP) and collapse runs
  238. $name = str_replace("\xC2\xA0", ' ', $fullName);
  239. $name = trim(preg_replace('/\s+/u', ' ', $name));
  240. if ($name === '') return 'there';
  241. // Strip a single pair of surrounding quotes if present
  242. if ($name !== '') {
  243. $q0 = $name[0];
  244. $q1 = substr($name, -1);
  245. if (($q0 === '"' && $q1 === '"') || ($q0 === "'" && $q1 === "'")) {
  246. $name = substr($name, 1, -1);
  247. $name = trim($name);
  248. }
  249. }
  250. // Strip common trailing suffixes
  251. $name = preg_replace(
  252. '/,?\s*(Jr\.?|Sr\.?|II|III|IV|MD|Ph\.?D|Esq\.?|J\.?D\.?|M\.?B\.?A\.?|RN|DDS|DMD)\s*$/iu',
  253. '',
  254. $name
  255. );
  256. // Remove one or more leading honorifics (with optional dot)
  257. $honorifics = '(mr|mrs|ms|miss|mx|dr|prof|sir|dame|lord|lady|hon|rev|fr|father|pastor|rabbi|imam|capt|cpt|gen|col|maj|sgt|officer|chief|coach|pres|sen|rep)';
  258. while (preg_match('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', $name)) {
  259. $name = preg_replace('/^' . $honorifics . '\.?[\s\x{00A0}]+/iu', '', $name, 1);
  260. }
  261. // First non-initial token
  262. foreach (preg_split('/[\s\x{00A0}]+/u', $name) as $tok) {
  263. $t = rtrim($tok, '.');
  264. if (!preg_match('/^[A-Za-z]\.?$/u', $t)) return $t;
  265. }
  266. return 'there';
  267. }
  268. function send_contract_email(string $email, string $clientid): bool {
  269. global $cfg;
  270. $mail = new PHPMailer(true);
  271. // Build the public link and load metadata from the .md file
  272. $link = contract_public_url($clientid);
  273. $mdPath = contract_path($clientid);
  274. $meta = extract_front_matter_fields($mdPath); // name, email, project, job includes client_name, job, admin.secret, etc.
  275. $clientName = $meta['client_name'] ?? '';
  276. $firstName = salutationFromName($clientName);
  277. $safeFirst = htmlspecialchars($firstName ?: 'there', ENT_QUOTES, 'UTF-8');
  278. $safeCompany = htmlspecialchars($cfg['company_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design'), ENT_QUOTES, 'UTF-8');
  279. $safeJob = htmlspecialchars($meta['job'] ?? $clientid, ENT_QUOTES, 'UTF-8');
  280. $safeSignature = email_logo_png_cid($mail, $cfg['dev_signature'] ?? '', $safeCompany, 100);
  281. // Prepared date from file mtime (or today if missing)
  282. $prepTs = @filemtime($mdPath) ?: time();
  283. $preparedPart = ' (prepared ' . date('j F Y', $prepTs) . ')';
  284. // Logo HTML: prefer full HTML from cfg; else build simple <img> if a URL is present
  285. $logoHtml = email_logo_png_cid($mail, $cfg['dark_logo'] ?? '', $safeCompany, 200);
  286. $safeUrl = htmlspecialchars($link, ENT_QUOTES, 'UTF-8');
  287. // Exact same visual structure as your contract.php style, but wording for "ready to sign"
  288. $html = build_admin_email_html_template(
  289. $logoHtml,
  290. $safeJob,
  291. $safeFirst,
  292. htmlspecialchars($preparedPart, ENT_QUOTES, 'UTF-8'),
  293. $safeUrl,
  294. $safeCompany,
  295. $safeSignature
  296. );
  297. $alt = "Hello {$safeFirst},\n\n"
  298. . "Your contract{$preparedPart} is ready to view and sign:\n"
  299. . "{$link}\n\n"
  300. . "Thanks again.\n"
  301. . "Kind Regards,\n{$safeCompany}";
  302. // Send with PHPMailer using your $cfg SMTP (same pattern as contract.php)
  303. try {
  304. $mail->CharSet = 'UTF-8';
  305. $mail->Encoding = 'base64';
  306. // SMTP if host present
  307. $smtpHost = $cfg['smtp_host'] ?? '';
  308. if ($smtpHost !== '') {
  309. $mail->isSMTP();
  310. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  311. $mail->Host = $smtpHost;
  312. $mail->SMTPAuth = true;
  313. $mail->Username = $cfg['smtp_username'] ?? '';
  314. $mail->Password = $cfg['smtp_password'] ?? '';
  315. $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl')); // 'ssl' or 'tls'
  316. if ($secure === 'ssl') {
  317. $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS;
  318. $mail->Port = (int)($cfg['smtp_port'] ?? 465);
  319. } else {
  320. $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
  321. $mail->Port = (int)($cfg['smtp_port'] ?? 587);
  322. }
  323. }
  324. $fromAddress = $cfg['smtp_from'] ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au');
  325. $fromName = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design');
  326. $mail->setFrom($fromAddress, $fromName);
  327. if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
  328. $mail->addAddress($email);
  329. // Optional BCC list (comma separated)
  330. if (!empty($cfg['smtp_bcc'])) {
  331. foreach (explode(',', $cfg['smtp_bcc']) as $bcc) {
  332. $bcc = trim($bcc);
  333. if ($bcc !== '') $mail->addBCC($bcc);
  334. }
  335. }
  336. $mail->isHTML(true);
  337. $mail->Subject = "Your Building Design contract";
  338. $mail->Body = $html;
  339. $mail->AltBody = $alt;
  340. $mail->addBCC('drafting@modulosdesign.com.au');
  341. $mail->send();
  342. return true;
  343. } catch (Throwable $e) {
  344. error_log("contracts-admin: PHPMailer failed for {$email}: ".$e->getMessage());
  345. // Fallback to mail()
  346. $headers = "MIME-Version: 1.0\r\n";
  347. $headers .= "Content-type: text/html; charset=UTF-8\r\n";
  348. $headers .= "From: ".$safeCompany." <".$fromAddress.">\r\n";
  349. return mail($email, "Your Building Design contract", $html, $headers);
  350. }
  351. }
  352. // Return absolute paths to every *.md under CONTRACTS_DIR (recursively)
  353. function list_all_contract_md_files(): array {
  354. $base = rtrim(CONTRACTS_DIR, '/\\');
  355. $files = [];
  356. if (!is_dir($base)) return $files;
  357. $it = new RecursiveIteratorIterator(
  358. new RecursiveDirectoryIterator(
  359. $base,
  360. FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
  361. ),
  362. RecursiveIteratorIterator::LEAVES_ONLY
  363. );
  364. foreach ($it as $path => $info) {
  365. if ($info->isFile() && strtolower($info->getExtension()) === 'md') {
  366. $files[] = $path;
  367. }
  368. }
  369. return $files;
  370. }
  371. // Pretty display path (relative to CONTRACTS_DIR)
  372. function contract_relpath(string $abs): string {
  373. $base = realpath(CONTRACTS_DIR) ?: CONTRACTS_DIR;
  374. $absR = realpath($abs) ?: $abs;
  375. $rel = ltrim(str_replace('\\','/', substr($absR, strlen($base))), '/');
  376. return $rel ?: basename($absR);
  377. }
  378. // Find the real path for {clientid}.md anywhere under CONTRACTS_DIR
  379. function find_contract_path_by_clientid(string $clientid): ?string {
  380. $id = safe_clientid($clientid);
  381. $needle = $id . '.md';
  382. // quick root check
  383. $direct = rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . $needle;
  384. if (is_file($direct)) return $direct;
  385. $it = new RecursiveIteratorIterator(
  386. new RecursiveDirectoryIterator(
  387. rtrim(CONTRACTS_DIR, '/\\'),
  388. FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS
  389. ),
  390. RecursiveIteratorIterator::LEAVES_ONLY
  391. );
  392. foreach ($it as $path => $info) {
  393. if ($info->isFile()
  394. && strtolower($info->getExtension()) === 'md'
  395. && strcasecmp($info->getFilename(), $needle) === 0) {
  396. return $path;
  397. }
  398. }
  399. return null;
  400. }
  401. /** Build a starter Markdown file with YAML front matter. */
  402. function build_markdown_template(string $clientid, ?string $name, ?string $email, ?string $project): string {
  403. $today = date('Y-m-d');
  404. $name = $name ?? '';
  405. $email = $email ?? '';
  406. $project = $project ?? '';
  407. // Generate secure random credentials
  408. $adminUser = bin2hex(random_bytes(4)); // 8 hex chars (~4 bytes)
  409. $adminPass = bin2hex(random_bytes(8)); // 16 hex chars (~8 bytes)
  410. $adminSecret = bin2hex(random_bytes(16)); // 32 hex chars (~16 bytes)
  411. $frontMatter = <<<YAML
  412. ---
  413. client:
  414. id: "{$clientid}"
  415. name: "{$name}"
  416. email: "{$email}"
  417. phone:
  418. address:
  419. project: "{$project}"
  420. dates:
  421. prepared: "{$today}"
  422. dev:
  423. name: 'Modulos Design'
  424. email: 'ben@modulos.com.au'
  425. phone: '0402 984 082'
  426. address: '34 Coplestone Street, Scottsdale, Tas 7260'
  427. version: 1
  428. quote:
  429. number: "{$clientid}"
  430. admin:
  431. user: "{$adminUser}"
  432. pass: "{$adminPass}"
  433. secret: "{$adminSecret}"
  434. ---
  435. YAML;
  436. $body = <<<YAML
  437. # Contract of work
  438. This Contract is made and entered into as of the date above by and between **[dev.name]** and **[client.name]** (hereinafter referred to as \"Client\").
  439. ##### 1. Scope of Services
  440. YAML;
  441. return $frontMatter . $body;
  442. }
  443. /* ------------------------- LOA HELPERS ------------------------- */
  444. function loa_path(string $job): string {
  445. $id = safe_clientid($job);
  446. return rtrim(LOA_DIR, '/\\') . DIRECTORY_SEPARATOR . $id . '.md';
  447. }
  448. function loa_public_url(string $job): string {
  449. $token = hash_hmac('sha256', 'loa|'.$job, LOA_TOKEN_SECRET);
  450. $signUrl= url_join(LOA_BASE_URL, 'loa.php');
  451. return $signUrl . '?job=' . rawurlencode($job) . '&token=' . rawurlencode($token);
  452. }
  453. /** Minimal front-matter pulls for LOA */
  454. function extract_loa_fields(string $file): array {
  455. $out = ['client_name'=>'','client_email'=>'','client_address'=>'','property_address'=>''];
  456. $txt = @file_get_contents($file);
  457. if (!$txt) return $out;
  458. if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) return $out;
  459. $fm = $m[1];
  460. $ctx = null;
  461. foreach (preg_split('/\R/', $fm) as $line) {
  462. // Any new TOP-LEVEL key (no leading spaces) resets context
  463. if (preg_match('/^\S[^:]*:\s*$/', $line)) {
  464. if (preg_match('/^client\s*:\s*$/', $line)) { $ctx = 'client'; }
  465. elseif (preg_match('/^property\s*:\s*$/', $line)) { $ctx = 'property'; }
  466. else { $ctx = null; }
  467. continue;
  468. }
  469. if ($ctx === 'client') {
  470. if (preg_match('/^\s*name\s*:\s*(.+)$/', $line, $mm)) $out['client_name'] = trim($mm[1], " \t\"'");
  471. if (preg_match('/^\s*email\s*:\s*(.+)$/', $line, $mm)) $out['client_email'] = trim($mm[1], " \t\"'");
  472. if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['client_address'] = trim($mm[1], " \t\"'");
  473. } elseif ($ctx === 'property') {
  474. if (preg_match('/^\s*address\s*:\s*(.+)$/', $line, $mm))$out['property_address']= trim($mm[1], " \t\"'");
  475. }
  476. }
  477. // Fallback: if no property address, use client address
  478. if ($out['property_address'] === '' && $out['client_address'] !== '') {
  479. $out['property_address'] = $out['client_address'];
  480. }
  481. return $out;
  482. }
  483. function lookup_job_for_loa(string $job): array {
  484. $job = safe_clientid($job);
  485. $empty = ['client_name'=>'','client_email'=>'','property_address'=>'','source'=>null];
  486. foreach (list_all_contract_md_files() as $file) {
  487. $txt = @file_get_contents($file); if (!$txt) continue;
  488. if (!preg_match('/^---\s*\R(.*?)\R---/s', $txt, $m)) continue;
  489. $fm = $m[1];
  490. $fm_job = null;
  491. if (preg_match('/^\s*job\s*:\s*["\']?([^"\r\n]+)["\']?/mi', $fm, $mm)) $fm_job = trim($mm[1]);
  492. $fname_id = clientid_from_filename($file);
  493. if ($fm_job === $job || $fname_id === $job) {
  494. $info = extract_front_matter_fields($file);
  495. $client_name = $info['client_name'] ?? '';
  496. $client_email = $info['client_email'] ?? '';
  497. $client_addr = null;
  498. if (preg_match('/^\s*client\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $block)
  499. && preg_match('/^\s*address\s*:\s*(.+)$/mi', $block[1], $ma)) {
  500. $client_addr = trim($ma[1], " \t\"'");
  501. }
  502. $prop_addr = null;
  503. if (preg_match('/^\s*property\s*:\s*$(.*?)^(?=\S)/ms', $fm."\nX", $pblock)
  504. && preg_match('/^\s*address\s*:\s*(.+)$/mi', $pblock[1], $mp)) {
  505. $prop_addr = trim($mp[1], " \t\"'");
  506. }
  507. return [
  508. 'client_name' => $client_name,
  509. 'client_email' => $client_email,
  510. 'property_address' => $prop_addr ?: $client_addr ?: '',
  511. 'source' => 'contract',
  512. 'clientid' => $fname_id,
  513. ];
  514. }
  515. }
  516. return $empty;
  517. }
  518. /* -------------------------------------------------------------------------- */
  519. /* API MODE */
  520. /* -------------------------------------------------------------------------- */
  521. $action = $_GET['action'] ?? $_POST['action'] ?? null;
  522. if ($action) {
  523. // For API calls, enforce admin auth except for mark_signed which uses a shared secret.
  524. if ($action !== 'mark_signed') {
  525. require_admin_auth();
  526. }
  527. try {
  528. switch ($action) {
  529. case 'list':
  530. $files = glob(rtrim(CONTRACTS_DIR, '/\\') . DIRECTORY_SEPARATOR . '*.md');
  531. $rows = [];
  532. foreach ($files as $file) {
  533. $clientid = clientid_from_filename($file);
  534. $stat = get_status($clientid);
  535. // build signed public URL (with token)
  536. $publicUrl = null;
  537. try {
  538. $publicUrl = contract_public_url($clientid);
  539. } catch (Throwable $e) {
  540. $publicUrl = null; // missing secret or bad front matter
  541. }
  542. $rows[] = [
  543. 'clientid' => $clientid,
  544. 'filename' => basename($file),
  545. 'size' => filesize($file),
  546. 'mtime' => filemtime($file),
  547. 'sent' => (int)($stat['sent'] ?? 0),
  548. 'sent_at' => $stat['sent_at'] ?? null,
  549. 'signed' => (int)($stat['signed'] ?? 0),
  550. 'signed_at' => $stat['signed_at'] ?? null,
  551. 'last_email_to' => $stat['last_email_to'] ?? null,
  552. 'public_url' => $publicUrl, // <-- add this
  553. ];
  554. }
  555. // Sort by most-recent modified first
  556. usort($rows, fn($a,$b) => ($b['mtime'] <=> $a['mtime']));
  557. json_response(['ok' => true, 'contracts' => $rows]);
  558. break;
  559. case 'read':
  560. $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? '');
  561. $path = contract_path($clientid);
  562. if (!file_exists($path)) {
  563. json_response(['ok' => false, 'error' => 'File not found'], 404);
  564. }
  565. $content = file_get_contents($path);
  566. json_response(['ok' => true, 'content' => $content]);
  567. break;
  568. case 'save':
  569. $clientid = safe_clientid($_POST['clientid'] ?? '');
  570. $content = $_POST['content'] ?? '';
  571. $path = contract_path($clientid);
  572. if (!is_dir(CONTRACTS_DIR)) {
  573. @mkdir(CONTRACTS_DIR, 0775, true);
  574. }
  575. $ok = file_put_contents($path, $content, LOCK_EX);
  576. if ($ok === false) {
  577. json_response(['ok' => false, 'error' => 'Write failed'], 500);
  578. }
  579. json_response(['ok' => true]);
  580. break;
  581. case 'send_link':
  582. $clientid = safe_clientid($_POST['clientid'] ?? '');
  583. $email = trim($_POST['email'] ?? '');
  584. if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
  585. json_response(['ok' => false, 'error' => 'Invalid email'], 400);
  586. }
  587. $ok = send_contract_email($email, $clientid);
  588. if ($ok) {
  589. set_sent($clientid, $email);
  590. json_response(['ok' => true]);
  591. } else {
  592. json_response(['ok' => false, 'error' => 'Failed to send email'], 500);
  593. }
  594. break;
  595. case 'mark_signed':
  596. // This endpoint is meant to be called from the public contracts.php after a successful signature
  597. $secret = $_GET['secret'] ?? $_POST['secret'] ?? '';
  598. if ($secret !== ADMIN_SHARED_SECRET) {
  599. json_response(['ok' => false, 'error' => 'Unauthorized'], 401);
  600. }
  601. $clientid = safe_clientid($_GET['clientid'] ?? $_POST['clientid'] ?? '');
  602. $signerName = $_GET['name'] ?? $_POST['name'] ?? null;
  603. $pdfPath = $_GET['pdf'] ?? $_POST['pdf'] ?? null;
  604. $ip = $_SERVER['REMOTE_ADDR'] ?? null;
  605. set_signed($clientid, $signerName, $ip, $pdfPath);
  606. json_response(['ok' => true]);
  607. break;
  608. case 'toggle_signed':
  609. // Manual override from the admin UI
  610. $clientid = safe_clientid($_POST['clientid'] ?? '');
  611. $flag = (int)($_POST['flag'] ?? 0);
  612. if ($flag) {
  613. set_signed($clientid, null, null, null);
  614. } else {
  615. $pdo = ensure_db();
  616. $stmt = $pdo->prepare("UPDATE contract_status SET signed = 0, signed_at = NULL WHERE clientid = :id");
  617. $stmt->execute([':id' => $clientid]);
  618. }
  619. json_response(['ok' => true]);
  620. break;
  621. case 'toggle_sent': {
  622. $clientid = safe_clientid($_POST['clientid'] ?? '');
  623. $flag = (int)($_POST['flag'] ?? 0);
  624. $pdo = ensure_db();
  625. if ($flag) {
  626. $stmt = $pdo->prepare("UPDATE contract_status SET sent = 1, sent_at = :now WHERE clientid = :id");
  627. $stmt->execute([':id' => $clientid, ':now' => date('Y-m-d H:i:s')]);
  628. } else {
  629. $stmt = $pdo->prepare("UPDATE contract_status SET sent = 0, sent_at = NULL WHERE clientid = :id");
  630. $stmt->execute([':id' => $clientid]);
  631. }
  632. json_response(['ok' => true]);
  633. break;
  634. }
  635. case 'create':
  636. // Create a new {clientid}.md using a starter template
  637. $clientid = safe_clientid($_POST['clientid'] ?? '');
  638. $name = trim($_POST['name'] ?? '');
  639. $email = trim($_POST['email'] ?? '');
  640. $project = trim($_POST['project'] ?? '');
  641. $overwrite = (int)($_POST['overwrite'] ?? 0);
  642. $path = contract_path($clientid);
  643. if (file_exists($path) && !$overwrite) {
  644. json_response(['ok' => false, 'error' => 'File already exists'], 409);
  645. }
  646. if (!is_dir(CONTRACTS_DIR)) {
  647. @mkdir(CONTRACTS_DIR, 0775, true);
  648. }
  649. $content = build_markdown_template($clientid, $name, $email, $project);
  650. $ok = file_put_contents($path, $content, LOCK_EX);
  651. if ($ok === false) {
  652. json_response(['ok' => false, 'error' => 'Create failed'], 500);
  653. }
  654. // Seed DB row if missing
  655. $pdo = ensure_db();
  656. try {
  657. $stmt = $pdo->prepare("INSERT OR IGNORE INTO contract_status (clientid, sent, signed) VALUES (:id, 0, 0)");
  658. $stmt->execute([':id' => $clientid]);
  659. } catch (Throwable $e) {}
  660. json_response(['ok' => true]);
  661. break;
  662. case 'loa_list': {
  663. require_admin_auth();
  664. $rows = [];
  665. foreach (glob(rtrim(LOA_DIR,'/\\').'/*.md') as $file) {
  666. $base = basename($file);
  667. if ($base === 'default-authorisation.md') continue;
  668. $job = clientid_from_filename($file); // filename without .md
  669. $st = @stat($file);
  670. $info = extract_loa_fields($file);
  671. $signedPdf = rtrim(LOA_DIR,'/\\')."/{$job}_signed_loa.pdf";
  672. $rows[] = [
  673. 'job' => $job,
  674. 'filename' => $base,
  675. 'mtime' => $st ? ($st['mtime'] ?? time()) : time(),
  676. 'client' => $info['client_name'],
  677. 'email' => $info['client_email'],
  678. 'address' => $info['property_address'],
  679. 'signed' => (int)file_exists($signedPdf),
  680. 'public_url' => loa_public_url($job),
  681. 'pdf_url' => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.pdf"),
  682. 'html_url' => url_join(LOA_BASE_URL, "loa/{$job}_signed_loa.html"),
  683. ];
  684. }
  685. usort($rows, fn($a,$b)=>($b['mtime']<=>$a['mtime']));
  686. json_response(['ok'=>true,'loas'=>$rows]); }
  687. case 'loa_read': {
  688. require_admin_auth();
  689. $job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? '');
  690. $path= loa_path($job);
  691. if (!file_exists($path)) json_response(['ok'=>false,'error'=>'File not found'],404);
  692. json_response(['ok'=>true,'content'=>file_get_contents($path)]);
  693. }
  694. case 'loa_save': {
  695. require_admin_auth();
  696. $job = safe_clientid($_POST['job'] ?? '');
  697. $content = $_POST['content'] ?? '';
  698. $path= loa_path($job);
  699. if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true);
  700. $ok = file_put_contents($path,$content,LOCK_EX);
  701. if ($ok===false) json_response(['ok'=>false,'error'=>'Write failed'],500);
  702. json_response(['ok'=>true]);
  703. }
  704. case 'loa_create': {
  705. require_admin_auth();
  706. $job = safe_clientid($_POST['job'] ?? '');
  707. $name = trim($_POST['client_name'] ?? '');
  708. $email = trim($_POST['client_email'] ?? '');
  709. $addr = trim($_POST['property_address'] ?? '');
  710. $dst = loa_path($job);
  711. if (file_exists($dst) && !((int)($_POST['overwrite'] ?? 0))) {
  712. json_response(['ok'=>false,'error'=>'File already exists'],409);
  713. }
  714. if (!is_dir(LOA_DIR)) @mkdir(LOA_DIR,0775,true);
  715. $tpl = @file_get_contents(rtrim(LOA_DIR,'/\\').'/default-authorisation.md') ?: "---\njob: {$job}\n---\n# Authorisation";
  716. // light substitutions
  717. $tpl = preg_replace('/^---\R(.+?)\R---/s', function($m) use($job,$name,$email,$addr){
  718. $yaml = $m[1];
  719. $yaml = preg_replace('/\bjob:\s*.*/','job: '.$job,$yaml);
  720. if ($name !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*name:.*$/m', '$1 name: '.$name, $yaml, 1);
  721. if ($email !== '') $yaml = preg_replace('/(client:\s*\R(?:.*\R)*?)^\s*email:.*$/m','$1 email: '.$email,$yaml,1);
  722. if ($addr !== '') $yaml = preg_replace('/(property:\s*\R(?:.*\R)*?)^\s*address:.*$/m','$1 address: '.$addr,$yaml,1);
  723. return "---\n".$yaml."\n---";
  724. }, $tpl, 1) ?? $tpl;
  725. file_put_contents($dst,$tpl);
  726. json_response(['ok'=>true]);
  727. }
  728. case 'loa_send_link': {
  729. require_admin_auth();
  730. $job = safe_clientid($_POST['job'] ?? '');
  731. $to = trim($_POST['email'] ?? '');
  732. if (!filter_var($to, FILTER_VALIDATE_EMAIL)) json_response(['ok'=>false,'error'=>'Invalid email'],400);
  733. $url = loa_public_url($job);
  734. $addr = extract_loa_fields(loa_path($job))['property_address'] ?: ('Job '.$job);
  735. $mail = new PHPMailer(true);
  736. try {
  737. // (mirror your SMTP bootstrap)
  738. $smtpHost = $cfg['smtp_host'] ?? '';
  739. if ($smtpHost) {
  740. $mail->isSMTP();
  741. $mail->SMTPDebug = SMTP::DEBUG_OFF;
  742. $mail->Host = $smtpHost;
  743. $mail->SMTPAuth = true;
  744. $mail->Username = $cfg['smtp_username'] ?? '';
  745. $mail->Password = $cfg['smtp_password'] ?? '';
  746. $secure = strtolower((string)($cfg['smtp_secure'] ?? 'ssl'));
  747. if ($secure === 'ssl') { $mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; $mail->Port = (int)($cfg['smtp_port'] ?? 465); }
  748. else { $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = (int)($cfg['smtp_port'] ?? 587); }
  749. }
  750. $fromAddress = $cfg['smtp_from'] ?? (defined('MAIL_FROM') ? MAIL_FROM : 'no-reply@modulosdesign.com.au');
  751. $fromName = $cfg['smtp_from_name'] ?? (defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : 'Modulos Design');
  752. $mail->setFrom($fromAddress, $fromName);
  753. if (!empty($cfg['smtp_reply_to'])) $mail->addReplyTo($cfg['smtp_reply_to']);
  754. $mail->addAddress($to);
  755. if (!empty($cfg['smtp_bcc'])) foreach (explode(',', $cfg['smtp_bcc']) as $bcc) { $bcc=trim($bcc); if ($bcc) $mail->addBCC($bcc); }
  756. // Always keep your audit trail BCC
  757. $mail->addBCC('drafting@modulosdesign.com.au');
  758. $btn = '<a href="'.htmlspecialchars($url,ENT_QUOTES).'" style="display:inline-block;padding:12px 22px;background:#635A4A;color:#fff;text-decoration:none">Open Authorisation</a>';
  759. $mail->isHTML(true);
  760. $mail->Subject = "Please review & sign your Authorisation - {$addr}";
  761. $mail->Body = "<p>Hi,</p><p>Please review and sign the Authorisation for <strong>".htmlspecialchars($addr,ENT_QUOTES)."</strong>.</p><p>{$btn}</p><p>If the button doesn't work, use this link:<br>".htmlspecialchars($url,ENT_QUOTES)."</p>";
  762. $mail->AltBody = "Please review and sign the Authorisation for {$addr}\n{$url}";
  763. $mail->send();
  764. json_response(['ok'=>true]);
  765. } catch (Throwable $e) {
  766. error_log('loa_send_link: '.$e->getMessage());
  767. json_response(['ok'=>false,'error'=>'Failed to send email'],500);
  768. }
  769. }
  770. case 'loa_lookup': {
  771. require_admin_auth();
  772. $job = safe_clientid($_GET['job'] ?? $_POST['job'] ?? '');
  773. $data = lookup_job_for_loa($job);
  774. $found = (bool)($data['client_name'] || $data['client_email'] || $data['property_address']);
  775. json_response(['ok' => true, 'found' => $found, 'data' => $data]);
  776. }
  777. default:
  778. json_response(['ok' => false, 'error' => 'Unknown action'], 400);
  779. }
  780. } catch (Throwable $e) {
  781. json_response(['ok' => false, 'error' => $e->getMessage()], 500);
  782. }
  783. exit;
  784. }
  785. /* -------------------------------------------------------------------------- */
  786. /* EMAIL TEMPLATE */
  787. /* Build the styled HTML email body identical in structure to contract.php, */
  788. /* adjusted for "ready to sign" wording. */
  789. /* -------------------------------------------------------------------------- */
  790. function build_admin_email_html_template(string $logoHtml, string $safeJob, string $firstNameSafe, string $preparedPartHtml, string $safeUrl, string $safeCompany, string $safeSignature): string {
  791. $html = <<<HTML
  792. <!-- Preheader stays hidden at 0px -->
  793. <div style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:0;line-height:0;">
  794. Your contract is ready to view and sign. Lets get started!
  795. </div>
  796. <div style="background:#f6f7fb;padding:24px;font-size:14px;line-height:1.6;">
  797. <table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="600"
  798. style="width:600px;max-width:100%;background:#ffffff;border-radius:8px;overflow:hidden;
  799. font-size:14px;line-height:1.6;font-family:Arial,Helvetica,sans-serif;">
  800. <tr>
  801. <td style="font-size:14px;line-height:1.6;padding:20px 24px;background:#D9CCC1;color:#ffffff;">
  802. <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-size:14px;line-height:1.6;">
  803. <tr>
  804. <td style="font-size:14px;line-height:1.6;">$logoHtml</td>
  805. <td align="right" style="font-weight:700;font-size:14px;line-height:1.6;">Job #$safeJob</td>
  806. </tr>
  807. </table>
  808. </td>
  809. </tr>
  810. <tr>
  811. <td style="padding:28px 24px 8px;line-height:1.6;color:#635A4A;font-size:14px;">
  812. <div style="font-size:14px;margin-bottom:8px;line-height:1.6;">Hello {$firstNameSafe},</div>
  813. <div style="font-size:14px;line-height:1.6;">
  814. Your contract{$preparedPartHtml} is ready to view and sign. Use the link below:
  815. </div>
  816. </td>
  817. </tr>
  818. <tr>
  819. <td align="center" style="padding:20px 24px 8px;font-size:14px;line-height:1.6;">
  820. <!--[if mso]>
  821. <v:rect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
  822. href="$safeUrl"
  823. style="height:42px;v-text-anchor:middle;width:240px;"
  824. stroked="f" fillcolor="#635A4A">
  825. <w:anchorlock/>
  826. <center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;line-height:1.6;">View Contract</center>
  827. </v:rect>
  828. <![endif]-->
  829. <!--[if !mso]><!-- -->
  830. <a href="$safeUrl"
  831. style="background:#635A4A;border-radius:0;display:inline-block;padding:12px 24px;color:#ffffff;
  832. text-decoration:none;font-weight:700;font-size:14px;line-height:1.6;mso-hide:all"
  833. target="_blank" rel="noopener">View Contract</a>
  834. <!--<![endif]-->
  835. </td>
  836. </tr>
  837. <tr>
  838. <td style="padding:8px 24px 24px;font-size:14px;line-height:1.6;color:#635A4A;">
  839. <div style="font-size:14px;line-height:1.6;">
  840. If the button doesn’t work, copy and paste this link into your browser:<br>
  841. <span style="word-break:break-all;color:#635A4A;font-size:14px;line-height:1.6;">$safeUrl</span>
  842. </div>
  843. <div style="font-size:14px;line-height:1.6;margin-top:18px;">
  844. Thank you once again. We’re excited to be working with you and look forward to getting started. Once the contract is signed, we will issue an invoice for the initial deposit and begin work as soon as we receive confirmation.<br><br>
  845. <b>Kind Regards,</b><br><br>$safeSignature<br>Benjamin Harris<br>$safeCompany<br>0402 984 082 | drafting@modulosdesign.com.au
  846. </div>
  847. </td>
  848. </tr>
  849. <tr>
  850. <td style="padding:12px 24px;background:#28261E;color:#D9CCC1;font-size:14px;line-height:1.6;">
  851. This is an automated message. Please reply to this email if you have any questions.
  852. </td>
  853. </tr>
  854. </table>
  855. </div>
  856. HTML;
  857. return $html;
  858. }
  859. // No action, render the admin UI
  860. require_admin_auth();
  861. ?>
  862. <!doctype html>
  863. <html lang="en">
  864. <head>
  865. <meta charset="utf-8">
  866. <meta name="viewport" content="width=device-width, initial-scale=1">
  867. <title>Contracts Admin</title>
  868. <link rel="shortcut icon" href="../../internal/images/blueprint.ico" type="image/x-icon">
  869. <meta name="robots" content="noindex">
  870. <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
  871. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
  872. <link href="../../internal/css/blueprint.css" rel="stylesheet">
  873. <link href="../../internal/css/print.css" rel="stylesheet" media="print">
  874. <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
  875. <style>
  876. body { background: #f7f7f7; }
  877. .badge-status { font-size: .85rem; }
  878. .status-sent { background: #e6f4ea; color: #0f5132; }
  879. .status-signed { background: #e7f1ff; color: #084298; }
  880. .monosmall { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: .85rem; }
  881. .pointer { cursor: pointer; }
  882. .table thead th { position: sticky; top: 0; background: #fff; z-index: 1; }
  883. </style>
  884. <script>window.CSRF = "<?php echo $csrf; ?>";</script>
  885. </head>
  886. <nav class="navbar navbar-expand-lg sticky-top bg-brown-dark brown-light border-bottom border-body d-print-none" data-bs-theme="dark">
  887. <div class="container-fluid">
  888. <span class="navbar-brand brown-light">
  889. <img src="../../internal/images/blueprint-logo-light.png" alt="Modulos Design" width="30" height="24" class="d-inline-block align-text-top" >
  890. Modulos Design
  891. </span>
  892. <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarContent" aria-controls="navbarContent" aria-expanded="false" aria-label="Toggle navigation">
  893. <span class="navbar-toggler-icon"></span>
  894. </button>
  895. <div class="collapse navbar-collapse" id="navbarContent">
  896. <ul class="navbar-nav me-auto mb-2 mb-lg-0">
  897. <li class="nav-item">
  898. <a class="nav-link active" aria-current="page" href="#">Home</a>
  899. </li>
  900. <li class="nav-item"><a class="nav-link <?= $tab==='contracts'?'active':'' ?>" href="?tab=contracts">Contracts</a></li>
  901. <li class="nav-item"><a class="nav-link <?= $tab==='loas'?'active':'' ?>" href="?tab=loas">LOAs</a></li>
  902. </ul>
  903. </div>
  904. </div>
  905. </nav>
  906. <body>
  907. <div class="container py-4">
  908. <?php if ($tab === 'contracts'): ?>
  909. <div class="row">
  910. <div class="col-12 col-md">
  911. <h1 class="h3 mb-0">Contracts Admin</h1>
  912. </div>
  913. <div class="col-12 col-md-4">
  914. <div class="input-group mb-3">
  915. <input id="search" type="search" class="form-control rounded-0" placeholder="Search client id or email">
  916. <button class="btn btn-sm bg-brown-five brown-three rounded-0" id="refreshBtn">Refresh Page</button>
  917. <button class="btn btn-sm bg-brown-three brown-five rounded-0" id="newBtn">Create New</button>
  918. </div>
  919. </div>
  920. </div>
  921. <div class="alert alert-info rounded-0">
  922. Contracts folder: <span class="monosmall"><?php echo htmlspecialchars(CONTRACTS_DIR, ENT_QUOTES, 'UTF-8'); ?></span>.
  923. Signing page base URL: <span class="monosmall"><?php echo htmlspecialchars(BASE_URL, ENT_QUOTES, 'UTF-8'); ?></span>
  924. </div>
  925. <div class="table-responsive">
  926. <table class="table table-hover align-middle" id="contractsTable">
  927. <thead>
  928. <tr>
  929. <th>Client ID</th>
  930. <th>Client</th>
  931. <th>Modified</th>
  932. <th>Sent</th>
  933. <th>Signed</th>
  934. <th>Email</th>
  935. <th>Actions</th>
  936. </tr>
  937. </thead>
  938. <tbody></tbody>
  939. </table>
  940. </div>
  941. </div>
  942. <!-- Edit Modal -->
  943. <div class="modal fade" id="editModal" tabindex="-1" aria-hidden="true">
  944. <div class="modal-dialog modal-xl modal-dialog-scrollable">
  945. <div class="modal-content">
  946. <div class="modal-header">
  947. <h5 class="modal-title">Edit Contract <span id="editClientId" class="text-muted"></span></h5>
  948. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  949. </div>
  950. <div class="modal-body">
  951. <textarea id="editContent" class="form-control" rows="20" spellcheck="false"></textarea>
  952. </div>
  953. <div class="modal-footer">
  954. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  955. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="saveBtn">Save</button>
  956. </div>
  957. </div>
  958. </div>
  959. </div>
  960. <!-- Send Modal -->
  961. <div class="modal fade" id="sendModal" tabindex="-1" aria-hidden="true">
  962. <div class="modal-dialog">
  963. <div class="modal-content">
  964. <div class="modal-header">
  965. <h5 class="modal-title">Email Contract Link</h5>
  966. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  967. </div>
  968. <div class="modal-body">
  969. <div class="mb-3">
  970. <label class="form-label">Send to email</label>
  971. <input id="sendEmail" type="email" class="form-control" placeholder="client@example.com">
  972. </div>
  973. <div class="alert alert-secondary">
  974. The email includes a link to the signing page for this client id.
  975. </div>
  976. </div>
  977. <div class="modal-footer">
  978. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  979. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="confirmSendBtn">Send</button>
  980. </div>
  981. </div>
  982. </div>
  983. </div>
  984. <!-- New Contract Modal -->
  985. <div class="modal fade" id="newModal" tabindex="-1" aria-hidden="true">
  986. <div class="modal-dialog">
  987. <div class="modal-content">
  988. <div class="modal-header">
  989. <h5 class="modal-title">Create new contract</h5>
  990. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  991. </div>
  992. <div class="modal-body">
  993. <div class="mb-3">
  994. <label class="form-label">Client ID (used for filename)</label>
  995. <input id="newClientId" type="text" class="form-control" placeholder="3043">
  996. <div class="form-text">Allowed letters, numbers, underscore, hyphen</div>
  997. </div>
  998. <div class="mb-3">
  999. <label class="form-label">Client name</label>
  1000. <input id="newName" type="text" class="form-control" placeholder="Client Name">
  1001. </div>
  1002. <div class="mb-3">
  1003. <label class="form-label">Client email</label>
  1004. <input id="newEmail" type="email" class="form-control" placeholder="client@example.com">
  1005. </div>
  1006. <div class="mb-3">
  1007. <label class="form-label">Project title</label>
  1008. <input id="newProject" type="text" class="form-control" placeholder="Project">
  1009. </div>
  1010. <div class="form-check">
  1011. <input class="form-check-input" type="checkbox" id="newOverwrite">
  1012. <label class="form-check-label" for="newOverwrite">Overwrite if file exists</label>
  1013. </div>
  1014. </div>
  1015. <div class="modal-footer">
  1016. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  1017. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="createBtn">Create</button>
  1018. </div>
  1019. </div>
  1020. </div>
  1021. </div>
  1022. <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
  1023. <script>
  1024. const tableBody = document.querySelector('#contractsTable tbody');
  1025. const searchInput = document.querySelector('#search');
  1026. const refreshBtn = document.querySelector('#refreshBtn');
  1027. const newBtn = document.querySelector('#newBtn');
  1028. let currentRows = [];
  1029. let currentEditId = null;
  1030. let currentSendId = null;
  1031. function fmtDate(ts) {
  1032. if (!ts) return '';
  1033. const d = new Date(ts * 1000);
  1034. return d.toLocaleString();
  1035. }
  1036. function linkFor(id) {
  1037. return '../contract.php?clientid=' + encodeURIComponent(id);
  1038. }
  1039. function renderTable(rows) {
  1040. tableBody.innerHTML = '';
  1041. rows.forEach(r => {
  1042. const url = r.public_url || ''; // empty string if missing
  1043. const tr = document.createElement('tr');
  1044. tr.innerHTML = `
  1045. <td class="monosmall">${r.clientid}</td>
  1046. <td>${r.filename}</td>
  1047. <td>${fmtDate(r.mtime)}</td>
  1048. <td>
  1049. ${r.sent ? '<span class="badge badge-status status-sent">✓ sent</span>' : '<span class="text-muted">—</span>'}
  1050. <button class="btn rounded-0 btn-sm ${r.sent ? 'btn-outline-danger' : 'btn-outline-success'} ms-2 toggleSentBtn">${r.sent ? 'Clear' : 'Mark sent'}</button>
  1051. </td>
  1052. <td>
  1053. ${r.signed ? '<span class="badge badge-status status-signed">✓ signed</span>' : '<span class="text-muted">—</span>'}
  1054. <button class="btn rounded-0 btn-sm ${r.signed ? 'btn-outline-danger' : 'btn-outline-success'} ms-2 toggleSignedBtn">${r.signed ? 'Clear' : 'Mark signed'}</button>
  1055. </td>
  1056. <td>${r.last_email_to ? r.last_email_to : ''}</td>
  1057. <td>
  1058. <div class="btn-group">
  1059. <button class="btn rounded-0 btn-sm bg-brown-five brown-three editBtn">Edit</button>
  1060. <button class="btn rounded-0 btn-sm bg-brown-three brown-five sendBtn">Email link</button>
  1061. <button class="btn rounded-0 btn-sm btn-outline-dark copyBtn" data-link="${url}">Copy link</button>
  1062. <a class="btn rounded-0 btn-sm btn-outline-secondary" href="${url}" target="_blank" rel="noopener">Open</a>
  1063. </div>
  1064. </td>`;
  1065. tr.querySelector('.editBtn').addEventListener('click', () => openEdit(r.clientid));
  1066. tr.querySelector('.sendBtn').addEventListener('click', () => openSend(r.clientid));
  1067. tr.querySelector('.copyBtn').addEventListener('click', (ev) => {
  1068. const url = ev.currentTarget.getAttribute('data-link') || '';
  1069. if (url) navigator.clipboard.writeText(url);
  1070. });
  1071. tr.querySelector('.toggleSignedBtn').addEventListener('click', async () => {
  1072. const flag = r.signed ? 0 : 1;
  1073. const fd = new FormData();
  1074. fd.append('action', 'toggle_signed');
  1075. fd.append('clientid', r.clientid);
  1076. fd.append('flag', flag);
  1077. fd.append('csrf', window.CSRF);
  1078. const res = await fetch('?action=toggle_signed', { method: 'POST', body: fd });
  1079. const js = await res.json();
  1080. if (js.ok) {
  1081. loadData();
  1082. } else {
  1083. alert(js.error || 'Failed');
  1084. }
  1085. });
  1086. tr.querySelector('.toggleSentBtn').addEventListener('click', async () => {
  1087. const flag = r.sent ? 0 : 1;
  1088. const fd = new FormData();
  1089. fd.append('action', 'toggle_sent');
  1090. fd.append('clientid', r.clientid);
  1091. fd.append('flag', flag);
  1092. fd.append('csrf', window.CSRF);
  1093. const res = await fetch('?action=toggle_sent', { method: 'POST', body: fd });
  1094. const js = await res.json();
  1095. if (js.ok) {
  1096. loadData();
  1097. } else {
  1098. alert(js.error || 'Failed');
  1099. }
  1100. });
  1101. tableBody.appendChild(tr);
  1102. });
  1103. }
  1104. async function loadData() {
  1105. const res = await fetch('?action=list');
  1106. const js = await res.json();
  1107. if (!js.ok) {
  1108. alert(js.error || 'Failed to load');
  1109. return;
  1110. }
  1111. currentRows = js.contracts;
  1112. applyFilter();
  1113. }
  1114. function applyFilter() {
  1115. const q = searchInput.value.trim().toLowerCase();
  1116. if (!q) {
  1117. renderTable(currentRows);
  1118. return;
  1119. }
  1120. const rows = currentRows.filter(r =>
  1121. r.clientid.toLowerCase().includes(q) ||
  1122. (r.last_email_to || '').toLowerCase().includes(q)
  1123. );
  1124. renderTable(rows);
  1125. }
  1126. async function openEdit(id) {
  1127. currentEditId = id;
  1128. const res = await fetch('?action=read&clientid=' + encodeURIComponent(id));
  1129. const js = await res.json();
  1130. if (!js.ok) { alert(js.error || 'Failed'); return; }
  1131. document.querySelector('#editClientId').textContent = id;
  1132. document.querySelector('#editContent').value = js.content;
  1133. const modal = new bootstrap.Modal('#editModal');
  1134. modal.show();
  1135. }
  1136. document.querySelector('#saveBtn').addEventListener('click', async () => {
  1137. const content = document.querySelector('#editContent').value;
  1138. const fd = new FormData();
  1139. fd.append('action', 'save');
  1140. fd.append('clientid', currentEditId);
  1141. fd.append('content', content);
  1142. fd.append('csrf', window.CSRF);
  1143. const res = await fetch('?action=save', { method: 'POST', body: fd });
  1144. const js = await res.json();
  1145. if (js.ok) {
  1146. bootstrap.Modal.getInstance(document.querySelector('#editModal')).hide();
  1147. loadData();
  1148. } else {
  1149. alert(js.error || 'Save failed');
  1150. }
  1151. });
  1152. function openSend(id) {
  1153. currentSendId = id;
  1154. document.querySelector('#sendEmail').value = '';
  1155. const modal = new bootstrap.Modal('#sendModal');
  1156. modal.show();
  1157. }
  1158. document.querySelector('#confirmSendBtn').addEventListener('click', async () => {
  1159. const email = document.querySelector('#sendEmail').value.trim();
  1160. if (!email) { alert('Please enter an email'); return; }
  1161. const fd = new FormData();
  1162. fd.append('action', 'send_link');
  1163. fd.append('clientid', currentSendId);
  1164. fd.append('email', email);
  1165. fd.append('csrf', window.CSRF);
  1166. const res = await fetch('?action=send_link', { method: 'POST', body: fd });
  1167. const js = await res.json();
  1168. if (js.ok) {
  1169. bootstrap.Modal.getInstance(document.querySelector('#sendModal')).hide();
  1170. loadData();
  1171. } else {
  1172. alert(js.error || 'Failed to send');
  1173. }
  1174. });
  1175. newBtn.addEventListener('click', () => {
  1176. document.querySelector('#newClientId').value = '';
  1177. document.querySelector('#newName').value = '';
  1178. document.querySelector('#newEmail').value = '';
  1179. document.querySelector('#newProject').value = '';
  1180. document.querySelector('#newOverwrite').checked = false;
  1181. const modal = new bootstrap.Modal('#newModal');
  1182. modal.show();
  1183. });
  1184. document.querySelector('#createBtn').addEventListener('click', async () => {
  1185. const id = document.querySelector('#newClientId').value.trim();
  1186. const name = document.querySelector('#newName').value.trim();
  1187. const email = document.querySelector('#newEmail').value.trim();
  1188. const project = document.querySelector('#newProject').value.trim();
  1189. const overwrite = document.querySelector('#newOverwrite').checked ? 1 : 0;
  1190. if (!id) { alert('Client ID is required'); return; }
  1191. const fd = new FormData();
  1192. fd.append('action', 'create');
  1193. fd.append('clientid', id);
  1194. fd.append('name', name);
  1195. fd.append('email', email);
  1196. fd.append('project', project);
  1197. fd.append('overwrite', overwrite);
  1198. fd.append('csrf', window.CSRF);
  1199. const res = await fetch('?action=create', { method: 'POST', body: fd });
  1200. const js = await res.json();
  1201. if (js.ok) {
  1202. bootstrap.Modal.getInstance(document.querySelector('#newModal')).hide();
  1203. loadData();
  1204. } else {
  1205. alert(js.error || 'Create failed');
  1206. }
  1207. });
  1208. searchInput.addEventListener('input', applyFilter);
  1209. refreshBtn.addEventListener('click', loadData);
  1210. loadData();
  1211. </script>
  1212. <?php else: /* LOAs tab */ ?>
  1213. <div class="row">
  1214. <div class="col-12 col-md">
  1215. <h1 class="h3 mb-0">LOA Admin</h1>
  1216. </div>
  1217. <div class="col-12 col-md-4">
  1218. <div class="input-group mb-3">
  1219. <input id="loaSearch" type="search" class="form-control rounded-0" placeholder="Search job, client, email">
  1220. <button class="btn btn-sm bg-brown-five brown-three rounded-0" id="loaRefreshBtn">Refresh Page</button>
  1221. <button class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaNewBtn">Create LOA</button>
  1222. </div>
  1223. </div>
  1224. </div>
  1225. <div class="alert alert-info rounded-0">
  1226. LOA folder: <span class="monosmall"><?= htmlspecialchars(LOA_DIR, ENT_QUOTES) ?></span>.
  1227. Signing page base URL: <span class="monosmall"><?= htmlspecialchars(LOA_BASE_URL, ENT_QUOTES) ?>/loa.php</span>
  1228. </div>
  1229. <div class="table-responsive">
  1230. <table class="table table-hover align-middle" id="loaTable">
  1231. <thead>
  1232. <tr>
  1233. <th>Job</th>
  1234. <th>Client</th>
  1235. <th>Property</th>
  1236. <th>Modified</th>
  1237. <th>Signed</th>
  1238. <th>Email</th>
  1239. <th>Actions</th>
  1240. </tr>
  1241. </thead>
  1242. <tbody></tbody>
  1243. </table>
  1244. </div>
  1245. <!-- LOA Edit Modal -->
  1246. <div class="modal fade" id="loaEditModal" tabindex="-1" aria-hidden="true">
  1247. <div class="modal-dialog modal-xl modal-dialog-scrollable">
  1248. <div class="modal-content">
  1249. <div class="modal-header"><h5 class="modal-title">Edit LOA <span id="loaEditJob" class="text-muted"></span></h5>
  1250. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
  1251. <div class="modal-body">
  1252. <textarea id="loaEditContent" class="form-control" rows="20" spellcheck="false"></textarea>
  1253. </div>
  1254. <div class="modal-footer">
  1255. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  1256. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaSaveBtn">Save</button>
  1257. </div>
  1258. </div>
  1259. </div>
  1260. </div>
  1261. <!-- LOA Send Modal -->
  1262. <div class="modal fade" id="loaSendModal" tabindex="-1" aria-hidden="true">
  1263. <div class="modal-dialog"><div class="modal-content">
  1264. <div class="modal-header"><h5 class="modal-title">Email LOA Link</h5>
  1265. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
  1266. <div class="modal-body">
  1267. <div class="mb-3"><label class="form-label">Send to email</label>
  1268. <input id="loaSendEmail" type="email" class="form-control" placeholder="client@example.com"></div>
  1269. <div class="alert alert-secondary">The email includes a link to the LOA signing page for this job.</div>
  1270. </div>
  1271. <div class="modal-footer">
  1272. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  1273. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaConfirmSendBtn">Send</button>
  1274. </div>
  1275. </div></div>
  1276. </div>
  1277. <!-- LOA New Modal -->
  1278. <div class="modal fade" id="loaNewModal" tabindex="-1" aria-hidden="true">
  1279. <div class="modal-dialog"><div class="modal-content">
  1280. <div class="modal-header"><h5 class="modal-title">Create new LOA</h5>
  1281. <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
  1282. <div class="modal-body">
  1283. <div class="mb-3"><label class="form-label">Job #</label>
  1284. <input id="loaNewJob" type="text" class="form-control" placeholder="1234"></div>
  1285. <div class="mb-3"><label class="form-label">Client name</label>
  1286. <input id="loaNewName" type="text" class="form-control" placeholder="Client Name"></div>
  1287. <div class="mb-3"><label class="form-label">Client email</label>
  1288. <input id="loaNewEmail" type="email" class="form-control" placeholder="client@example.com"></div>
  1289. <div class="mb-3"><label class="form-label">Property address</label>
  1290. <input id="loaNewAddress" type="text" class="form-control" placeholder="1 Example St, Scottsdale TAS"></div>
  1291. <div class="form-check">
  1292. <input class="form-check-input" type="checkbox" id="loaNewOverwrite">
  1293. <label class="form-check-label" for="loaNewOverwrite">Overwrite if file exists</label>
  1294. </div>
  1295. </div>
  1296. <div class="modal-footer">
  1297. <button type="button" class="btn btn-sm bg-brown-five brown-three rounded-0" data-bs-dismiss="modal">Close</button>
  1298. <button type="button" class="btn btn-sm bg-brown-three brown-five rounded-0" id="loaCreateBtn">Create</button>
  1299. </div>
  1300. </div></div>
  1301. </div>
  1302. <script>
  1303. (() => {
  1304. const tbody = document.querySelector('#loaTable tbody');
  1305. const search = document.querySelector('#loaSearch');
  1306. const refresh = document.querySelector('#loaRefreshBtn');
  1307. const btnNew = document.querySelector('#loaNewBtn');
  1308. let rows = [];
  1309. let currentJob = null;
  1310. function fmtDate(ts){ if(!ts) return ''; const d=new Date(ts*1000); return d.toLocaleString(); }
  1311. function render(list){
  1312. tbody.innerHTML = '';
  1313. list.forEach(r => {
  1314. const tr = document.createElement('tr');
  1315. tr.innerHTML = `
  1316. <td class="monosmall">${r.job}</td>
  1317. <td>${r.client || ''}</td>
  1318. <td>${r.address || ''}</td>
  1319. <td>${fmtDate(r.mtime)}</td>
  1320. <td>${r.signed ? '<span class="badge badge-status status-signed">✓ signed</span>' : '<span class="text-muted">—</span>'}</td>
  1321. <td>${r.email || ''}</td>
  1322. <td>
  1323. <div class="btn-group">
  1324. <button class="btn rounded-0 btn-sm bg-brown-five brown-three loaEditBtn">Edit</button>
  1325. <button class="btn rounded-0 btn-sm bg-brown-three brown-five loaSendBtn">Email link</button>
  1326. <button class="btn rounded-0 btn-sm btn-outline-dark loaCopyBtn">Copy link</button>
  1327. <a class="btn rounded-0 btn-sm btn-outline-secondary" href="${r.public_url}" target="_blank" rel="noopener">Open</a>
  1328. <a class="btn rounded-0 btn-sm btn-outline-success" href="${r.pdf_url}" target="_blank">PDF</a>
  1329. <a class="btn rounded-0 btn-sm btn-outline-success" href="${r.html_url}" target="_blank">HTML</a>
  1330. </div>
  1331. </td>`;
  1332. tr.querySelector('.loaEditBtn').addEventListener('click', () => openEdit(r.job));
  1333. tr.querySelector('.loaSendBtn').addEventListener('click', () => openSend(r.job, r.email));
  1334. tr.querySelector('.loaCopyBtn').addEventListener('click', () => navigator.clipboard.writeText(r.public_url));
  1335. tbody.appendChild(tr);
  1336. });
  1337. }
  1338. async function load(){
  1339. const res = await fetch('?action=loa_list');
  1340. const js = await res.json();
  1341. if (!js.ok){ alert(js.error || 'Failed to load'); return; }
  1342. rows = js.loas;
  1343. applyFilter();
  1344. }
  1345. function applyFilter(){
  1346. const q = (search.value||'').toLowerCase().trim();
  1347. if (!q) return render(rows);
  1348. const f = rows.filter(r =>
  1349. r.job.toLowerCase().includes(q) ||
  1350. (r.client||'').toLowerCase().includes(q) ||
  1351. (r.email||'').toLowerCase().includes(q) ||
  1352. (r.address||'').toLowerCase().includes(q)
  1353. );
  1354. render(f);
  1355. }
  1356. async function openEdit(job){
  1357. currentJob = job;
  1358. const res = await fetch('?action=loa_read&job='+encodeURIComponent(job));
  1359. const js = await res.json();
  1360. if (!js.ok){ alert(js.error||'Failed'); return; }
  1361. document.querySelector('#loaEditJob').textContent = job;
  1362. document.querySelector('#loaEditContent').value = js.content;
  1363. new bootstrap.Modal('#loaEditModal').show();
  1364. }
  1365. document.querySelector('#loaSaveBtn').addEventListener('click', async () => {
  1366. const content = document.querySelector('#loaEditContent').value;
  1367. const fd = new FormData();
  1368. fd.append('action','loa_save');
  1369. fd.append('job', currentJob);
  1370. fd.append('content', content);
  1371. fd.append('csrf', window.CSRF);
  1372. const res = await fetch('?action=loa_save', { method:'POST', body: fd });
  1373. const js = await res.json();
  1374. if (js.ok) {
  1375. bootstrap.Modal.getInstance(document.querySelector('#loaEditModal')).hide();
  1376. load();
  1377. } else alert(js.error || 'Save failed');
  1378. });
  1379. function openSend(job, prefill){
  1380. currentJob = job;
  1381. document.querySelector('#loaSendEmail').value = prefill || '';
  1382. new bootstrap.Modal('#loaSendModal').show();
  1383. }
  1384. document.querySelector('#loaConfirmSendBtn').addEventListener('click', async () => {
  1385. const email = document.querySelector('#loaSendEmail').value.trim();
  1386. if (!email) { alert('Please enter an email'); return; }
  1387. const fd = new FormData();
  1388. fd.append('action','loa_send_link');
  1389. fd.append('job', currentJob);
  1390. fd.append('email', email);
  1391. fd.append('csrf', window.CSRF);
  1392. const res = await fetch('?action=loa_send_link', { method:'POST', body: fd });
  1393. const js = await res.json();
  1394. if (js.ok) {
  1395. bootstrap.Modal.getInstance(document.querySelector('#loaSendModal')).hide();
  1396. load();
  1397. } else alert(js.error || 'Failed to send');
  1398. });
  1399. btnNew.addEventListener('click', ()=>{
  1400. document.querySelector('#loaNewJob').value='';
  1401. document.querySelector('#loaNewName').value='';
  1402. document.querySelector('#loaNewEmail').value='';
  1403. document.querySelector('#loaNewAddress').value='';
  1404. document.querySelector('#loaNewOverwrite').checked=false;
  1405. new bootstrap.Modal('#loaNewModal').show();
  1406. });
  1407. document.querySelector('#loaCreateBtn').addEventListener('click', async ()=>{
  1408. const job = document.querySelector('#loaNewJob').value.trim();
  1409. if (!job) { alert('Job # is required'); return; }
  1410. const fd = new FormData();
  1411. fd.append('action','loa_create');
  1412. fd.append('job', job);
  1413. fd.append('client_name', document.querySelector('#loaNewName').value.trim());
  1414. fd.append('client_email', document.querySelector('#loaNewEmail').value.trim());
  1415. fd.append('property_address', document.querySelector('#loaNewAddress').value.trim());
  1416. fd.append('overwrite', document.querySelector('#loaNewOverwrite').checked ? 1 : 0);
  1417. fd.append('csrf', window.CSRF);
  1418. const res = await fetch('?action=loa_create', { method:'POST', body: fd });
  1419. const js = await res.json();
  1420. if (js.ok) {
  1421. bootstrap.Modal.getInstance(document.querySelector('#loaNewModal')).hide();
  1422. load();
  1423. } else alert(js.error || 'Create failed');
  1424. });
  1425. search.addEventListener('input', applyFilter);
  1426. refresh.addEventListener('click', load);
  1427. load();
  1428. })();
  1429. const jobInput = document.querySelector('#loaNewJob');
  1430. const nameInput = document.querySelector('#loaNewName');
  1431. const emailInput= document.querySelector('#loaNewEmail');
  1432. const addrInput = document.querySelector('#loaNewAddress');
  1433. function debounce(fn, ms){ let t; return (...args)=>{ clearTimeout(t); t=setTimeout(()=>fn(...args), ms); }; }
  1434. const lookupJob = debounce(async () => {
  1435. const job = jobInput.value.trim();
  1436. if (!job) return;
  1437. try {
  1438. const res = await fetch('?action=loa_lookup&job=' + encodeURIComponent(job));
  1439. const js = await res.json();
  1440. if (js.ok && js.found && js.data) {
  1441. const d = js.data;
  1442. if (d.client_name && !nameInput.value) nameInput.value = d.client_name;
  1443. if (d.client_email && !emailInput.value) emailInput.value = d.client_email;
  1444. if (d.property_address && !addrInput.value) addrInput.value = d.property_address;
  1445. // quick visual hint
  1446. [nameInput, emailInput, addrInput].forEach(el => {
  1447. if (el.value) { el.classList.add('is-valid'); setTimeout(()=>el.classList.remove('is-valid'), 1200); }
  1448. });
  1449. }
  1450. } catch(e) { /* ignore */ }
  1451. }, 300);
  1452. jobInput.addEventListener('input', lookupJob);
  1453. jobInput.addEventListener('blur', lookupJob);
  1454. </script>
  1455. <?php endif; ?>
  1456. <footer class="footer fixed-bottom">
  1457. <div class="container">
  1458. <div class="row text-center">
  1459. <p>© 2025 - Modulos Design</p>
  1460. </div>
  1461. </div>
  1462. </footer>
  1463. </body>
  1464. </html>