1 Commits

4 changed files with 45 additions and 168 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ All notable changes to this project will be documented in this file.
- Ops - Ops
- Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions. - Systemd service now uses `EnvironmentFile` for production env vars; fixed `EnvironmentFile` typo and validated permissions.
- Switched service restart policy to `Restart=always` for resilience. - Switched service restart policy to `Restart=always` for resilience.
- Confirmed service listens on `PORT` (default 4000) and added guidance for restarts after env changes. - Confirmed service listens on `PORT` (default 3020) and added guidance for restarts after env changes.
- Excel Sync - Excel Sync
- Addressed column mismatch errors by refreshing Excel table column cache on service restart; sync confirmed working. - Addressed column mismatch errors by refreshing Excel table column cache on service restart; sync confirmed working.
- Versioning - Versioning
+3 -3
View File
@@ -1,6 +1,6 @@
# Job Creation Form with Excel Sync # Job Creation Form with Excel Sync
> Current Version: v1.0.0-beta2 > Current Version: v1.0.0-beta3a
See CHANGELOG.md for release notes. See CHANGELOG.md for release notes.
@@ -48,7 +48,7 @@ ITEM_ID=01SPNXLDQRICHB63BFUNGKZ3GE6RL7LGBT
EXCEL_TABLE=/drives/.../tables/Test_Table EXCEL_TABLE=/drives/.../tables/Test_Table
# Server # Server
PORT=4000 PORT=3020
``` ```
## Installation ## Installation
@@ -67,7 +67,7 @@ Start the server:
bun run dev bun run dev
``` ```
Server runs at: http://localhost:4000 Server runs at: http://localhost:3020
## Key Features ## Key Features
+35 -125
View File
@@ -62,11 +62,11 @@
<div class="grid grid-cols-1 gap-6 mb-6"> <div class="grid grid-cols-1 gap-6 mb-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2"> <label for="Job_Name" class="block text-sm font-medium text-gray-700 mb-2">
Job Name <span class="text-red-600">*</span> Job Name
</label> </label>
<input type="text" id="Job_Name" name="Job_Name" required placeholder="Enter job name" autocomplete="off" <input type="text" id="Job_Name" name="Job_Name" placeholder="Enter job name" autocomplete="off"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Use a descriptive name (no symbols-only, no N/A).</p> <p class="text-xs text-gray-500 mt-1">Optional. Provide if known.</p>
</div> </div>
</div> </div>
@@ -167,7 +167,7 @@
<label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label> <label for="Phone_Number" class="block text-sm font-medium text-gray-700 mb-2">Phone Number</label>
<input type="tel" id="Phone_Number" name="Phone_Number" placeholder="(###) ###-####" autocomplete="tel" <input type="tel" id="Phone_Number" name="Phone_Number" placeholder="(###) ###-####" autocomplete="tel"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
<p class="text-xs text-gray-500 mt-1">Use format (###) ###-####. Extension can go in EXT.</p> <p class="text-xs text-gray-500 mt-1">Optional. Format: (###) ###-####. Extension can go in EXT.</p>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
@@ -240,6 +240,7 @@
<option value="" disabled selected>Select office rep</option> <option value="" disabled selected>Select office rep</option>
<option value="Beth Cardoza">Beth Cardoza</option> <option value="Beth Cardoza">Beth Cardoza</option>
<option value="Steve Brewer">Steve Brewer</option> <option value="Steve Brewer">Steve Brewer</option>
<option value="Timothy Cardoza">Timothy Cardoza</option>
</select> </select>
</div> </div>
@@ -404,6 +405,10 @@
</div> </div>
</div> </div>
<!-- FUTURE: Job Size Guess and Guess Source
To be enabled in future: allow sq ft, linear ft, and/or dollar amount
Job_Size_Guess: Basic financial cost/value of the job
Job_Size_Guess_Source: Who provided the guess (Estimator, Rick, Project Manager, etc.)
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
<div class="flex flex-col"> <div class="flex flex-col">
<label for="Job_Size_Guess" class="block text-sm font-medium text-gray-700 mb-2" title="Basic financial cost/value of the job. Example: $25,000.00 (just a guess).">Job Size Guess</label> <label for="Job_Size_Guess" class="block text-sm font-medium text-gray-700 mb-2" title="Basic financial cost/value of the job. Example: $25,000.00 (just a guess).">Job Size Guess</label>
@@ -417,6 +422,7 @@
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all"> class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all">
</div> </div>
</div> </div>
-->
</div> </div>
<button type="submit" id="submitBtn" disabled <button type="submit" id="submitBtn" disabled
@@ -530,12 +536,11 @@
// Function to check if required fields are filled // Function to check if required fields are filled
function checkRequiredFields() { function checkRequiredFields() {
const jobName = document.getElementById('Job_Name').value.trim();
const officeRep = document.getElementById('Office_Rep').value; const officeRep = document.getElementById('Office_Rep').value;
const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked'); const taxExempt = document.querySelector('input[name="Tax_Exempt"]:checked');
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
if (jobName && officeRep && taxExempt) { if (officeRep && taxExempt) {
submitBtn.disabled = false; submitBtn.disabled = false;
} else { } else {
submitBtn.disabled = true; submitBtn.disabled = true;
@@ -585,27 +590,8 @@
} }
// Add event listeners to required fields // Add event listeners to required fields
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
document.getElementById('Office_Rep').addEventListener('change', checkRequiredFields); document.getElementById('Office_Rep').addEventListener('change', checkRequiredFields);
// Job name field: validate on blur for 4-char minimum
document.getElementById('Job_Name').addEventListener('blur', (e) => {
const jobName = (e.target.value || '').toString().trim();
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
const hasLetters = /[A-Za-z]/.test(jobName);
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
const isLongEnough = jobName.length >= 4;
if (jobName.length > 0 && (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral)) {
e.target.classList.add('border-red-500', 'bg-red-50');
e.target.title = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).';
} else {
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.title = '';
}
});
// Phone number field: validate and format on blur // Phone number field: validate and format on blur
document.getElementById('Phone_Number').addEventListener('blur', (e) => { document.getElementById('Phone_Number').addEventListener('blur', (e) => {
const phoneRaw = (e.target.value || '').toString().trim(); const phoneRaw = (e.target.value || '').toString().trim();
@@ -656,49 +642,22 @@
} }
}); });
// Job Address field: validate address or coordinates on blur // No validation on Job Address blur (optional)
document.getElementById('Job_Address').addEventListener('blur', (e) => {
const addr = (e.target.value || '').toString().trim();
if (!addr) {
e.target.classList.remove('border-red-500', 'bg-red-50', 'border-green-500', 'bg-green-50');
e.target.title = '';
return; // Empty is allowed, will check Address_Notes on submit
}
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
const isCoordinates = coordPattern.test(addr);
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
const hasDigit = /\d/.test(addr);
const hasAlpha = /[A-Za-z]/.test(addr);
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
if (isCoordinates || isAddress) {
e.target.classList.remove('border-red-500', 'bg-red-50');
e.target.classList.add('border-green-500', 'bg-green-50');
e.target.title = isCoordinates ? 'Valid coordinates format' : 'Valid address format';
} else {
e.target.classList.remove('border-green-500', 'bg-green-50');
e.target.classList.add('border-red-500', 'bg-red-50');
e.target.title = 'Address must be like "123 Main St, City, State" or coordinates like "34.0522, -118.2437". Add directions in Address Notes if needed.';
}
});
document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders); document.getElementById('Office_Rep').addEventListener('change', updateSelectPlaceholders);
// Job Size Guess: format as USD on blur // FUTURE: Job Size Guess formatter (when field is re-enabled)
const jobSizeInput = document.getElementById('Job_Size_Guess'); // const jobSizeInput = document.getElementById('Job_Size_Guess');
if (jobSizeInput) { // if (jobSizeInput) {
jobSizeInput.addEventListener('blur', (e) => { // jobSizeInput.addEventListener('blur', (e) => {
const raw = (e.target.value || '').toString().trim(); // const raw = (e.target.value || '').toString().trim();
if (!raw) return; // if (!raw) return;
// Keep only digits and one decimal point // const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1');
const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1'); // const num = parseFloat(cleaned);
const num = parseFloat(cleaned); // if (isNaN(num)) return;
if (isNaN(num)) return; // e.target.value = num.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 });
e.target.value = num.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }); // });
}); // }
}
document.querySelectorAll('input[name="Tax_Exempt"]').forEach(radio => { document.querySelectorAll('input[name="Tax_Exempt"]').forEach(radio => {
radio.addEventListener('change', () => { radio.addEventListener('change', () => {
checkRequiredFields(); checkRequiredFields();
@@ -747,23 +706,11 @@
return; return;
} }
// Enforce phone format (###) ###-####; reject otherwise // FUTURE: Phone number format coercion
const phoneRaw = (data.Phone_Number || '').toString(); // When re-enabled, enforce strict format: (###) ###-#### or "Unknown" (or blank to allow)
const isUnknown = phoneRaw.toLowerCase() === 'unknown'; // Currently: Phone is optional, no validation on submit
if (!isUnknown) { const phoneRaw = (data.Phone_Number || '').toString().trim();
const digits = phoneRaw.replace(/\D/g, '').slice(0, 10); data.Phone_Number = phoneRaw; // Accept as-is if provided, or empty if blank
if (digits.length === 10) {
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
data.Phone_Number = formatted;
} else {
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
message.textContent = 'Phone number must be 10 digits or "Unknown". Add extensions in EXT field.';
message.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Job';
return;
}
}
// Add user's PocketBase token // Add user's PocketBase token
data.pbToken = pb.authStore.token; data.pbToken = pb.authStore.token;
@@ -788,47 +735,10 @@
data.Start_Date = formatDateToShort(data.Start_Date); data.Start_Date = formatDateToShort(data.Start_Date);
} }
// Address validation: require plausible address, coordinates, OR Address_Notes // Address and Job Name: optional, no validation
const addr = (data.Job_Address || '').toString().trim(); data.Job_Address = (data.Job_Address || '').toString().trim();
const addrNotes = (data.Address_Notes || '').toString().trim(); data.Address_Notes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').toString().trim();
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
const isCoordinates = coordPattern.test(addr);
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
const hasDigit = /\d/.test(addr);
const hasAlpha = /[A-Za-z]/.test(addr);
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
const addressLooksValid = isCoordinates || isAddress;
if (!addressLooksValid && addrNotes.length < 5) {
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
message.textContent = 'Provide a real address (e.g., 123 Main St, City, State), coordinates (e.g., 34.0522, -118.2437), or enter directions in Address Notes.';
message.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Job';
return;
}
data.Job_Address = addr;
data.Address_Notes = addrNotes;
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a"
const jobName = (data.Job_Name || '').toString().trim();
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
const hasLetters = /[A-Za-z]/.test(jobName);
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
const isLongEnough = jobName.length >= 4;
if (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) {
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
message.textContent = 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).';
message.classList.remove('hidden');
submitBtn.disabled = false;
submitBtn.textContent = 'Submit Job';
return;
}
data.Job_Name = jobName;
// Convert Tax_Exempt to boolean // Convert Tax_Exempt to boolean
if (data.Tax_Exempt) { if (data.Tax_Exempt) {
@@ -952,7 +862,7 @@
<!-- Version Info --> <!-- Version Info -->
<div class="fixed bottom-4 right-4 text-xs text-gray-400"> <div class="fixed bottom-4 right-4 text-xs text-gray-400">
v1.0.0-beta3 v1.0.0-beta3a
</div> </div>
</body> </body>
</html> </html>
+6 -39
View File
@@ -273,7 +273,7 @@ function calculateJobFullName(record: any): string {
const companyClient = record.Company_Client || record.company_client || ''; const companyClient = record.Company_Client || record.company_client || '';
const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim()); const parts = [jobName, jobAddress, companyClient].filter(p => p && String(p).trim());
return parts.map(p => String(p).trim()).join(' - ').trim(); return parts.map(p => String(p).trim()).join(' ').trim();
} }
/** /**
@@ -518,7 +518,7 @@ async function addToExcel(record: any, pbId: string) {
// Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client) // Job_Full_Name: CONCAT(Job_Name, " - ", Job_Address, " - ", Company_Client)
if (colLower === 'job_full_name') { if (colLower === 'job_full_name') {
return calculateJobFullName(record); return '=CONCATENATE([@[Job_Name]]," ",[@[Job_Address]]," ",[@[Company_Client]])';
} }
// Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days) // Due_Date_Counter: Status-aware due date counter (ASAP, Passed due, Due today, X days)
@@ -693,43 +693,10 @@ app.post('/api/submit', async (c) => {
if (dueIso) data.Due_Date = dueIso; if (dueIso) data.Due_Date = dueIso;
if (startIso) data.Start_Date = startIso; if (startIso) data.Start_Date = startIso;
// Address validation: require plausible address, coordinates, OR Address_Notes provided // Address and Job_Name are optional; trim but do not validate
const addr = (data.Job_Address || '').toString().trim(); data.Job_Address = (data.Job_Address || '').toString().trim();
const addrNotes = (data.Address_Notes || '').toString().trim(); data.Address_Notes = (data.Address_Notes || '').toString().trim();
data.Job_Name = (data.Job_Name || '').toString().trim();
// Check if it looks like coordinates: pattern "lat, lon" or "lat,lon"
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
const isCoordinates = coordPattern.test(addr);
// Check if it looks like a real address: has digit(s) and letter(s), at least 5 chars
const hasDigit = /\d/.test(addr);
const hasAlpha = /[A-Za-z]/.test(addr);
const isAddress = addr.length >= 5 && hasDigit && hasAlpha;
const addressLooksValid = isCoordinates || isAddress;
if (!addressLooksValid && addrNotes.length < 5) {
return c.json({
success: false,
message: 'Provide a real address (e.g., 123 Main St, City, State), coordinates (e.g., 34.0522, -118.2437), or enter directions in Address Notes.'
}, 400);
}
data.Job_Address = addr;
data.Address_Notes = addrNotes;
// Job name validation: must be at least 4 characters, contain letters, not symbols-only, not "n/a"
const jobName = (data.Job_Name || '').toString().trim();
const invalidLiterals = ['n/a', 'na', 'n.a.', 'n\\a', 'none'];
const onlySymbols = /^[^A-Za-z0-9]+$/.test(jobName);
const hasLetters = /[A-Za-z]/.test(jobName);
const isInvalidLiteral = invalidLiterals.includes(jobName.toLowerCase());
const isLongEnough = jobName.length >= 4;
if (!isLongEnough || !hasLetters || onlySymbols || isInvalidLiteral) {
return c.json({
success: false,
message: 'Job name must be at least 4 characters with letters (no symbols-only, no N/A).'
}, 400);
}
data.Job_Name = jobName;
// Enforce Job_Type and Job_Division selection // Enforce Job_Type and Job_Division selection
const jobType = (data.Job_Type || '').toString().trim(); const jobType = (data.Job_Type || '').toString().trim();