Fields are working now as expected. need to remove job size gues and guess source and move schedule confidence to beside start date, but this gets us back on the right path.
This commit is contained in:
+402
-7
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Job Creation Form</title>
|
<title>Job Creation Form</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script>
|
<script>
|
||||||
tailwind.config = {
|
tailwind.config = {
|
||||||
@@ -64,6 +66,7 @@
|
|||||||
</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" required 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -162,8 +165,9 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<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="Enter phone number" 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -173,11 +177,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="EXT" class="block text-sm font-medium text-gray-700 mb-2">EXT (optional)</label>
|
||||||
|
<input type="text" id="EXT" name="EXT" placeholder="Extension" 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">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="Fax" class="block text-sm font-medium text-gray-700 mb-2">Fax (optional)</label>
|
||||||
|
<input type="text" id="Fax" name="Fax" placeholder="Fax number" 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">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="Contact_Notes" class="block text-sm font-medium text-gray-700 mb-2">Contact Notes (optional)</label>
|
||||||
|
<textarea id="Contact_Notes" name="Contact_Notes" rows="2" placeholder="Add extra contact details or instructions"
|
||||||
|
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"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
|
<label for="Job_Address" class="block text-sm font-medium text-gray-700 mb-2">Job Address</label>
|
||||||
<input type="text" id="Job_Address" name="Job_Address" placeholder="Enter job address" autocomplete="street-address"
|
<div class="flex gap-2">
|
||||||
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">
|
<input type="text" id="Job_Address" name="Job_Address" placeholder="e.g., 123 Main St, Los Angeles, CA 90012 or 34.0522, -118.2437" autocomplete="street-address"
|
||||||
|
class="flex-1 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">
|
||||||
|
<button type="button" id="openMapPicker" class="px-4 py-2.5 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-2 whitespace-nowrap">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Pick on Map
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Examples: <strong>123 Main St, Los Angeles, CA</strong> or coordinates: <strong>34.0522, -118.2437</strong>. Click "Pick on Map" to select location visually.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="Address_Notes" class="block text-sm font-medium text-gray-700 mb-2">Address Notes (required if address not precise)</label>
|
||||||
|
<textarea id="Address_Notes" name="Address_Notes" rows="2" placeholder="Add directions or location notes if the address is incomplete"
|
||||||
|
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"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -234,8 +278,9 @@
|
|||||||
<!-- Start Date (moved to top) -->
|
<!-- Start Date (moved to top) -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2">Start Date</label>
|
<label for="Start_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Estimated date work will start.">Start Date</label>
|
||||||
<input type="text" id="Start_Date" name="Start_Date" class="datepicker 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" placeholder="Select start date" autocomplete="off">
|
<input type="text" id="Start_Date" name="Start_Date" class="datepicker 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" placeholder="Select start date" autocomplete="off">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Estimated date work will start.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -263,19 +308,48 @@
|
|||||||
<!-- Due Date and Time (now third) -->
|
<!-- Due Date and Time (now third) -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2">Due Date</label>
|
<label for="Due_Date" class="block text-sm font-medium text-gray-700 mb-2" title="Date estimate is due to client.">Due Date</label>
|
||||||
<input type="text" id="Due_Date" name="Due_Date" class="datepicker 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" placeholder="Select due date" autocomplete="off">
|
<input type="text" id="Due_Date" name="Due_Date" class="datepicker 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" placeholder="Select due date" autocomplete="off">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Date estimate is due to client.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label>
|
<label for="Due_Time" class="block text-sm font-medium text-gray-700 mb-2">Due Time</label>
|
||||||
<input type="text" id="Due_Time" name="Due_Time" placeholder="Enter time" autocomplete="off"
|
<input type="text" id="Due_Time" name="Due_Time" list="due-time-options" placeholder="Select or type" 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">
|
||||||
|
<datalist id="due-time-options">
|
||||||
|
<option value="5:00 AM"></option>
|
||||||
|
<option value="5:30 AM"></option>
|
||||||
|
<option value="6:00 AM"></option>
|
||||||
|
<option value="6:30 AM"></option>
|
||||||
|
<option value="7:00 AM"></option>
|
||||||
|
<option value="7:30 AM"></option>
|
||||||
|
<option value="8:00 AM"></option>
|
||||||
|
<option value="8:30 AM"></option>
|
||||||
|
<option value="9:00 AM"></option>
|
||||||
|
<option value="9:30 AM"></option>
|
||||||
|
<option value="10:00 AM"></option>
|
||||||
|
<option value="10:30 AM"></option>
|
||||||
|
<option value="11:00 AM"></option>
|
||||||
|
<option value="11:30 AM"></option>
|
||||||
|
<option value="12:00 PM"></option>
|
||||||
|
<option value="12:30 PM"></option>
|
||||||
|
<option value="1:00 PM"></option>
|
||||||
|
<option value="1:30 PM"></option>
|
||||||
|
<option value="2:00 PM"></option>
|
||||||
|
<option value="2:30 PM"></option>
|
||||||
|
<option value="3:00 PM"></option>
|
||||||
|
<option value="3:30 PM"></option>
|
||||||
|
<option value="4:00 PM"></option>
|
||||||
|
<option value="4:30 PM"></option>
|
||||||
|
<option value="5:00 PM"></option>
|
||||||
|
</datalist>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Choose a time or type a custom value.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Due Date Source (now last) -->
|
<!-- Due Date Source (now last) -->
|
||||||
<div class="grid grid-cols-1 gap-6">
|
<div class="grid grid-cols-1 gap-6 mb-6">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
|
<label class="block text-sm font-medium text-gray-700 mb-2">Due Date Source</label>
|
||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
@@ -294,6 +368,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Any other information -->
|
||||||
|
<div class="grid grid-cols-1 gap-6">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="Notes" class="block text-sm font-medium text-gray-700 mb-2">Any other information we need to know? (optional)</label>
|
||||||
|
<textarea id="Notes" name="Notes" rows="3" placeholder="Add any additional details, special instructions, or notes"
|
||||||
|
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"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -320,6 +403,20 @@
|
|||||||
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
<p id="taxReminder" class="hidden mt-3 text-sm font-semibold text-red-700">Don't forget to file the paperwork!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-6">
|
||||||
|
<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>
|
||||||
|
<input type="text" id="Job_Size_Guess" name="Job_Size_Guess" placeholder="$0,000.00 (guess)"
|
||||||
|
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">Enter a rough value (e.g., $25,000.00). Will auto-format in USD.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<label for="Job_Size_Guess_Source" class="block text-sm font-medium text-gray-700 mb-2" title="Who provided this guess? Estimator, Rick, Project Manager, etc.">Guess Source</label>
|
||||||
|
<input type="text" id="Job_Size_Guess_Source" name="Job_Size_Guess_Source" placeholder="Estimator, Rick, PM, etc."
|
||||||
|
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>
|
||||||
|
|
||||||
<button type="submit" id="submitBtn" disabled
|
<button type="submit" id="submitBtn" disabled
|
||||||
@@ -331,6 +428,37 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Picker Modal -->
|
||||||
|
<div id="mapPickerModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
|
||||||
|
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-bold text-gray-800">Select Location on Map</h2>
|
||||||
|
<button type="button" id="closeMapPicker" class="text-gray-500 hover:text-gray-700 transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 relative">
|
||||||
|
<div id="map" class="w-full h-full" style="min-height: 500px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="p-4 border-t border-gray-200 flex items-center justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-gray-600">Click anywhere on the map to drop a pin. Selected coordinates will be added to Job Address.</p>
|
||||||
|
<p id="selectedCoords" class="text-sm font-medium text-primary mt-1"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button type="button" id="cancelMapPicker" class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" id="confirmMapPicker" class="px-4 py-2 bg-primary text-white rounded-lg hover:opacity-90 transition-opacity" disabled>
|
||||||
|
Use Coordinates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/pocketbase/dist/pocketbase.umd.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -459,7 +587,118 @@
|
|||||||
// Add event listeners to required fields
|
// Add event listeners to required fields
|
||||||
document.getElementById('Job_Name').addEventListener('input', checkRequiredFields);
|
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
|
||||||
|
document.getElementById('Phone_Number').addEventListener('blur', (e) => {
|
||||||
|
const phoneRaw = (e.target.value || '').toString().trim();
|
||||||
|
if (!phoneRaw) return; // Allow empty
|
||||||
|
|
||||||
|
const isUnknown = phoneRaw.toLowerCase() === 'unknown';
|
||||||
|
if (isUnknown) {
|
||||||
|
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = '';
|
||||||
|
e.target.value = 'Unknown';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = phoneRaw.replace(/\D/g, '').slice(0, 10);
|
||||||
|
if (digits.length === 10) {
|
||||||
|
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
e.target.value = formatted;
|
||||||
|
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = `Phone formatted as: ${formatted}`;
|
||||||
|
} else {
|
||||||
|
e.target.classList.add('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = `Phone must be 10 digits (found ${digits.length}) or enter "Unknown".`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fax field: validate and format on blur (same as phone)
|
||||||
|
document.getElementById('Fax').addEventListener('blur', (e) => {
|
||||||
|
const faxRaw = (e.target.value || '').toString().trim();
|
||||||
|
if (!faxRaw) return; // Allow empty (optional field)
|
||||||
|
|
||||||
|
const isUnknown = faxRaw.toLowerCase() === 'unknown';
|
||||||
|
if (isUnknown) {
|
||||||
|
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = '';
|
||||||
|
e.target.value = 'Unknown';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digits = faxRaw.replace(/\D/g, '').slice(0, 10);
|
||||||
|
if (digits.length === 10) {
|
||||||
|
const formatted = `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||||
|
e.target.value = formatted;
|
||||||
|
e.target.classList.remove('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = `Fax formatted as: ${formatted}`;
|
||||||
|
} else {
|
||||||
|
e.target.classList.add('border-red-500', 'bg-red-50');
|
||||||
|
e.target.title = `Fax must be 10 digits (found ${digits.length}) or enter "Unknown".`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Job Address field: validate address or coordinates on blur
|
||||||
|
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
|
||||||
|
const jobSizeInput = document.getElementById('Job_Size_Guess');
|
||||||
|
if (jobSizeInput) {
|
||||||
|
jobSizeInput.addEventListener('blur', (e) => {
|
||||||
|
const raw = (e.target.value || '').toString().trim();
|
||||||
|
if (!raw) return;
|
||||||
|
// Keep only digits and one decimal point
|
||||||
|
const cleaned = raw.replace(/[^0-9.]/g, '').replace(/(\..*?)\./g, '$1');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
if (isNaN(num)) return;
|
||||||
|
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();
|
||||||
@@ -495,6 +734,36 @@
|
|||||||
|
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
const data = Object.fromEntries(formData.entries());
|
const data = Object.fromEntries(formData.entries());
|
||||||
|
|
||||||
|
// Enforce Job_Type and Job_Division selection
|
||||||
|
const jobType = (data.Job_Type || '').toString().trim();
|
||||||
|
const jobDivision = (data.Job_Division || '').toString().trim();
|
||||||
|
if (!jobType || !jobDivision) {
|
||||||
|
message.className = 'p-4 rounded-lg font-medium bg-red-50 text-red-700 border border-red-200';
|
||||||
|
message.textContent = 'Please select both Job Type and Job Division.';
|
||||||
|
message.classList.remove('hidden');
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = 'Submit Job';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce phone format (###) ###-####; reject otherwise
|
||||||
|
const phoneRaw = (data.Phone_Number || '').toString();
|
||||||
|
const isUnknown = phoneRaw.toLowerCase() === 'unknown';
|
||||||
|
if (!isUnknown) {
|
||||||
|
const digits = phoneRaw.replace(/\D/g, '').slice(0, 10);
|
||||||
|
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;
|
||||||
@@ -518,7 +787,49 @@
|
|||||||
if (data.Start_Date) {
|
if (data.Start_Date) {
|
||||||
data.Start_Date = formatDateToShort(data.Start_Date);
|
data.Start_Date = formatDateToShort(data.Start_Date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Address validation: require plausible address, coordinates, OR Address_Notes
|
||||||
|
const addr = (data.Job_Address || '').toString().trim();
|
||||||
|
const addrNotes = (data.Address_Notes || '').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) {
|
||||||
data.Tax_Exempt = data.Tax_Exempt === 'true';
|
data.Tax_Exempt = data.Tax_Exempt === 'true';
|
||||||
@@ -553,6 +864,90 @@
|
|||||||
submitBtn.textContent = 'Submit Job';
|
submitBtn.textContent = 'Submit Job';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Map Picker Functionality
|
||||||
|
let map = null;
|
||||||
|
let marker = null;
|
||||||
|
let selectedLatLng = null;
|
||||||
|
|
||||||
|
document.getElementById('openMapPicker').addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('mapPickerModal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Initialize map if not already done
|
||||||
|
if (!map) {
|
||||||
|
// Default to 1836 North Deffer Drive, Nixa, MO 65714
|
||||||
|
map = L.map('map').setView([37.0415, -93.2985], 13);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Handle map clicks
|
||||||
|
map.on('click', (e) => {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
|
||||||
|
// Remove existing marker if any
|
||||||
|
if (marker) {
|
||||||
|
map.removeLayer(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new marker
|
||||||
|
marker = L.marker([lat, lng]).addTo(map);
|
||||||
|
|
||||||
|
// Store coordinates
|
||||||
|
selectedLatLng = { lat, lng };
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
document.getElementById('selectedCoords').textContent = `Selected: ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
document.getElementById('confirmMapPicker').disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate size to ensure map renders correctly in modal
|
||||||
|
setTimeout(() => {
|
||||||
|
map.invalidateSize();
|
||||||
|
|
||||||
|
// If Job_Address already has coordinates, center on them
|
||||||
|
const currentAddr = document.getElementById('Job_Address').value.trim();
|
||||||
|
const coordPattern = /^-?\d+\.?\d*\s*,\s*-?\d+\.?\d*$/;
|
||||||
|
if (coordPattern.test(currentAddr)) {
|
||||||
|
const [lat, lng] = currentAddr.split(',').map(s => parseFloat(s.trim()));
|
||||||
|
if (!isNaN(lat) && !isNaN(lng)) {
|
||||||
|
map.setView([lat, lng], 15);
|
||||||
|
if (marker) map.removeLayer(marker);
|
||||||
|
marker = L.marker([lat, lng]).addTo(map);
|
||||||
|
selectedLatLng = { lat, lng };
|
||||||
|
document.getElementById('selectedCoords').textContent = `Selected: ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||||
|
document.getElementById('confirmMapPicker').disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('closeMapPicker').addEventListener('click', () => {
|
||||||
|
document.getElementById('mapPickerModal').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('cancelMapPicker').addEventListener('click', () => {
|
||||||
|
document.getElementById('mapPickerModal').classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirmMapPicker').addEventListener('click', () => {
|
||||||
|
if (selectedLatLng) {
|
||||||
|
const coordString = `${selectedLatLng.lat.toFixed(6)}, ${selectedLatLng.lng.toFixed(6)}`;
|
||||||
|
document.getElementById('Job_Address').value = coordString;
|
||||||
|
|
||||||
|
// Trigger blur validation
|
||||||
|
const addressInput = document.getElementById('Job_Address');
|
||||||
|
addressInput.classList.remove('border-red-500', 'bg-red-50');
|
||||||
|
addressInput.classList.add('border-green-500', 'bg-green-50');
|
||||||
|
addressInput.title = 'Valid coordinates format';
|
||||||
|
|
||||||
|
document.getElementById('mapPickerModal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
|
|||||||
@@ -693,6 +693,54 @@ 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
|
||||||
|
const addr = (data.Job_Address || '').toString().trim();
|
||||||
|
const addrNotes = (data.Address_Notes || '').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
|
||||||
|
const jobType = (data.Job_Type || '').toString().trim();
|
||||||
|
const jobDivision = (data.Job_Division || '').toString().trim();
|
||||||
|
if (!jobType || !jobDivision) {
|
||||||
|
return c.json({
|
||||||
|
success: false,
|
||||||
|
message: 'Please select both Job Type and Job Division.'
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Get all existing job numbers and find the max
|
// Get all existing job numbers and find the max
|
||||||
const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({
|
const existingJobs = await pb.collection(process.env.PB_COLLECTION!).getFullList({
|
||||||
fields: 'Job_Number',
|
fields: 'Job_Number',
|
||||||
|
|||||||
Reference in New Issue
Block a user