build out client page, edit functionality, create functionality, data massager

This commit is contained in:
Casey 2025-11-19 22:25:16 -06:00
parent f510645a31
commit 34f2c110d6
15 changed files with 1571 additions and 1681 deletions

View file

@ -0,0 +1,826 @@
<template>
<div class="overview-container">
<!-- Client Basic Info Card -->
<div class="info-card">
<div class="card-header">
<h3>Client Information</h3>
<Button
v-if="!isNew && !editMode"
@click="toggleEditMode"
icon="pi pi-pencil"
label="Edit"
size="small"
severity="secondary"
/>
</div>
<div class="info-grid">
<div class="info-item">
<label>Customer Name:</label>
<AutoComplete
v-if="isNew || editMode"
v-model="formData.customerName"
:suggestions="customerSuggestions"
@complete="searchCustomers"
@item-select="onCustomerSelect"
placeholder="Type or select customer name"
class="w-full"
:disabled="isSubmitting"
/>
<span v-else>{{ clientData?.customerName || "N/A" }}</span>
</div>
<div class="info-item">
<label>Customer Type:</label>
<Select
v-if="isNew || editMode"
v-model="formData.customerType"
:options="customerTypeOptions"
placeholder="Select customer type"
class="w-full"
:disabled="isSubmitting"
/>
<span v-else>{{ clientData?.customerType || "N/A" }}</span>
</div>
<div class="info-item" v-if="!isNew && !editMode">
<label>Customer Group:</label>
<span>{{ clientData?.customerGroup || "N/A" }}</span>
</div>
<div class="info-item" v-if="!isNew && !editMode">
<label>Territory:</label>
<span>{{ clientData?.territory || "N/A" }}</span>
</div>
</div>
</div>
<!-- Address Info Card -->
<div class="info-card">
<h3>Address Information</h3>
<div class="info-grid">
<div class="info-item" v-if="isNew || editMode">
<label>Address Line 1 *:</label>
<InputText
v-model="formData.addressLine1"
placeholder="Street address"
class="w-full"
:disabled="isSubmitting"
required
/>
</div>
<div class="info-item" v-if="isNew || editMode">
<label>Address Line 2:</label>
<InputText
v-model="formData.addressLine2"
placeholder="Apt, suite, unit, etc."
class="w-full"
:disabled="isSubmitting"
/>
</div>
<div class="info-item" v-if="isNew || editMode">
<label>Zip Code *:</label>
<InputText
v-model="formData.zipcode"
placeholder="12345"
@input="handleZipcodeInput"
maxlength="5"
class="w-full"
:disabled="isSubmitting"
required
/>
</div>
<div class="info-item" v-if="isNew || editMode">
<label>City *:</label>
<InputText
v-model="formData.city"
placeholder="City"
class="w-full"
:disabled="isSubmitting || zipcodeLookupDisabled"
required
/>
</div>
<div class="info-item" v-if="isNew || editMode">
<label>State *:</label>
<InputText
v-model="formData.state"
placeholder="State"
class="w-full"
:disabled="isSubmitting || zipcodeLookupDisabled"
required
/>
</div>
<!-- Read-only mode for existing clients -->
<div
class="info-item full-width"
v-if="!isNew && !editMode && selectedAddressData"
>
<label>Full Address:</label>
<span>{{ fullAddress }}</span>
</div>
<div class="info-item" v-if="!isNew && !editMode && selectedAddressData">
<label>City:</label>
<span>{{ selectedAddressData.city || "N/A" }}</span>
</div>
<div class="info-item" v-if="!isNew && !editMode && selectedAddressData">
<label>State:</label>
<span>{{ selectedAddressData.state || "N/A" }}</span>
</div>
<div class="info-item" v-if="!isNew && !editMode && selectedAddressData">
<label>Zip Code:</label>
<span>{{ selectedAddressData.pincode || "N/A" }}</span>
</div>
</div>
</div>
<!-- Contact Info Card (only for new/edit mode) -->
<div class="info-card" v-if="isNew || editMode">
<h3>Contact Information</h3>
<div class="info-grid">
<div class="info-item">
<label>Contact Name *:</label>
<AutoComplete
v-model="formData.contactName"
:suggestions="contactSuggestions"
@complete="searchContacts"
@item-select="onContactSelect"
placeholder="Type or select contact name"
class="w-full"
:disabled="isSubmitting || !formData.customerName"
required
/>
</div>
<div class="info-item">
<label>Phone Number:</label>
<InputText
v-model="formData.phoneNumber"
placeholder="(555) 123-4567"
class="w-full"
:disabled="isSubmitting"
/>
</div>
<div class="info-item">
<label>Email:</label>
<InputText
v-model="formData.email"
placeholder="email@example.com"
type="email"
class="w-full"
:disabled="isSubmitting"
/>
</div>
</div>
</div>
<!-- Status Cards (only for existing clients) -->
<div class="status-cards" v-if="!isNew && !editMode && selectedAddressData">
<div class="status-card">
<h4>On-Site Meeting</h4>
<Badge
:value="selectedAddressData.customOnsiteMeetingScheduled || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customOnsiteMeetingScheduled)"
/>
</div>
<div class="status-card">
<h4>Estimate Sent</h4>
<Badge
:value="selectedAddressData.customEstimateSentStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customEstimateSentStatus)"
/>
</div>
<div class="status-card">
<h4>Job Status</h4>
<Badge
:value="selectedAddressData.customJobStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customJobStatus)"
/>
</div>
<div class="status-card">
<h4>Payment Received</h4>
<Badge
:value="selectedAddressData.customPaymentReceivedStatus || 'Not Started'"
:severity="getStatusSeverity(selectedAddressData.customPaymentReceivedStatus)"
/>
</div>
</div>
<!-- Form Actions -->
<div class="form-actions" v-if="isNew || editMode">
<Button
@click="handleCancel"
label="Cancel"
severity="secondary"
:disabled="isSubmitting"
/>
<Button
@click="handleSave"
:label="isNew ? 'Create Client' : 'Save Changes'"
:loading="isSubmitting"
:disabled="!isFormValid"
/>
</div>
<!-- Location Map (only for existing clients) -->
<div class="map-card" v-if="!isNew && !editMode">
<h3>Location</h3>
<LeafletMap
:latitude="latitude"
:longitude="longitude"
:address-title="selectedAddressData?.addressTitle || 'Client Location'"
map-height="350px"
:zoom-level="16"
/>
<div v-if="latitude && longitude" class="coordinates-info">
<small>
<strong>Coordinates:</strong>
{{ parseFloat(latitude).toFixed(6) }}, {{ parseFloat(longitude).toFixed(6) }}
</small>
</div>
</div>
<!-- Edit Confirmation Dialog -->
<Dialog
v-model:visible="showEditConfirmDialog"
header="Confirm Edit"
:modal="true"
:closable="false"
class="confirm-dialog"
>
<p>
Are you sure you want to edit this client information? This will enable editing
mode.
</p>
<template #footer>
<Button
label="Cancel"
severity="secondary"
@click="showEditConfirmDialog = false"
/>
<Button label="Yes, Edit" @click="confirmEdit" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { computed, ref, watch, onMounted } from "vue";
import Badge from "primevue/badge";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import AutoComplete from "primevue/autocomplete";
import Select from "primevue/select";
import Dialog from "primevue/dialog";
import LeafletMap from "../common/LeafletMap.vue";
import DataUtils from "../../utils";
import Api from "../../api";
import { useRouter } from "vue-router";
import { useNotificationStore } from "../../stores/notifications-primevue";
const props = defineProps({
clientData: {
type: Object,
default: () => ({}),
},
selectedAddress: {
type: String,
default: "",
},
isNew: {
type: Boolean,
default: false,
},
});
const router = useRouter();
const notificationStore = useNotificationStore();
// Form state
const editMode = ref(false);
const showEditConfirmDialog = ref(false);
const isSubmitting = ref(false);
const zipcodeLookupDisabled = ref(true);
// Form data
const formData = ref({
customerName: "",
customerType: "",
addressLine1: "",
addressLine2: "",
zipcode: "",
city: "",
state: "",
contactName: "",
phoneNumber: "",
email: "",
});
// Autocomplete data
const customerSuggestions = ref([]);
const contactSuggestions = ref([]);
const selectedCustomerData = ref(null);
// Options
const customerTypeOptions = ref(["Company", "Individual"]);
// Initialize form data when component mounts
onMounted(() => {
if (props.isNew) {
// Initialize empty form for new client
resetForm();
console.log("Mounted in new client mode - initialized empty form");
} else if (props.clientData && Object.keys(props.clientData).length > 0) {
// Populate form with existing client data
populateFormFromClientData();
console.log("Mounted with existing client data - populated form");
} else {
// Default to empty form if no client data
resetForm();
console.log("Mounted with no client data - initialized empty form");
}
});
// Watch for clientData changes
watch(
() => props.clientData,
(newData) => {
if (props.isNew) {
// Always keep form empty for new clients, regardless of clientData
resetForm();
} else if (newData && Object.keys(newData).length > 0) {
populateFormFromClientData();
} else {
// No client data, reset form
resetForm();
}
},
{ deep: true },
);
// Watch for isNew prop changes to reset form when switching to new client mode
watch(
() => props.isNew,
(isNewValue) => {
if (isNewValue) {
// Reset form when switching to new client mode
resetForm();
editMode.value = false;
console.log("Switched to new client mode - reset form data");
} else if (props.clientData && Object.keys(props.clientData).length > 0) {
// Populate form when switching back to existing client
populateFormFromClientData();
} else {
// No client data, reset form
resetForm();
}
},
{ immediate: false },
);
// Find the address data object that matches the selected address string
const selectedAddressData = computed(() => {
if (!props.clientData?.addresses || !props.selectedAddress) {
return null;
}
return props.clientData.addresses.find(
(addr) => DataUtils.calculateFullAddress(addr) === props.selectedAddress,
);
});
// Get coordinates from the selected address
const latitude = computed(() => {
if (!selectedAddressData.value) return null;
// Check custom fields first, then fallback to regular fields
return selectedAddressData.value.customLatitude || selectedAddressData.value.latitude || null;
});
const longitude = computed(() => {
if (!selectedAddressData.value) return null;
// Check custom fields first, then fallback to regular fields
return (
selectedAddressData.value.customLongitude || selectedAddressData.value.longitude || null
);
});
// Calculate full address for display
const fullAddress = computed(() => {
if (!selectedAddressData.value) return "N/A";
return DataUtils.calculateFullAddress(selectedAddressData.value);
});
// Form validation
const isFormValid = computed(() => {
return (
formData.value.customerName &&
formData.value.customerType &&
formData.value.addressLine1 &&
formData.value.zipcode &&
formData.value.city &&
formData.value.state &&
formData.value.contactName
);
});
// Helper function to get badge severity based on status
const getStatusSeverity = (status) => {
switch (status) {
case "Not Started":
return "secondary";
case "In Progress":
return "warn"; // Use 'warn' instead of 'warning' for PrimeVue Badge
case "Completed":
return "success";
default:
return "secondary";
}
};
// Form methods
const resetForm = () => {
formData.value = {
customerName: "",
customerType: "",
addressLine1: "",
addressLine2: "",
zipcode: "",
city: "",
state: "",
contactName: "",
phoneNumber: "",
email: "",
};
selectedCustomerData.value = null;
zipcodeLookupDisabled.value = false; // Allow manual entry for new clients
editMode.value = false; // Ensure edit mode is off
console.log("Form reset - all fields cleared");
};
const populateFormFromClientData = () => {
if (!selectedAddressData.value) return;
formData.value = {
customerName: props.clientData.customerName || "",
customerType: props.clientData.customerType || "",
addressLine1: selectedAddressData.value.addressLine1 || "",
addressLine2: selectedAddressData.value.addressLine2 || "",
zipcode: selectedAddressData.value.pincode || "",
city: selectedAddressData.value.city || "",
state: selectedAddressData.value.state || "",
contactName: selectedAddressData.value.customContactName || "",
phoneNumber: selectedAddressData.value.phone || "",
email: selectedAddressData.value.emailId || "",
};
};
// Edit mode methods
const toggleEditMode = () => {
showEditConfirmDialog.value = true;
};
const confirmEdit = () => {
showEditConfirmDialog.value = false;
editMode.value = true;
populateFormFromClientData(); // Refresh form with current data
};
// Zipcode handling
const handleZipcodeInput = async (event) => {
const input = event.target.value;
// Only allow digits
const digitsOnly = input.replace(/\D/g, "");
// Limit to 5 digits
if (digitsOnly.length > 5) {
return;
}
formData.value.zipcode = digitsOnly;
// Fetch city/state when 5 digits entered
if (digitsOnly.length === 5) {
try {
const places = await Api.getCityStateByZip(digitsOnly);
if (places && places.length > 0) {
// Auto-populate city and state
formData.value.city = places[0]["place name"];
formData.value.state = places[0]["state abbreviation"];
zipcodeLookupDisabled.value = true;
notificationStore.addSuccess(
`Found: ${places[0]["place name"]}, ${places[0]["state abbreviation"]}`,
);
}
} catch (error) {
// Enable manual entry if lookup fails
zipcodeLookupDisabled.value = false;
notificationStore.addWarning(
"Could not find city/state for this zip code. Please enter manually.",
);
}
} else {
// Reset city/state if zipcode is incomplete
if (zipcodeLookupDisabled.value) {
formData.value.city = "";
formData.value.state = "";
}
}
};
// Customer search
const searchCustomers = async (event) => {
try {
const customers = await Api.getCustomerNames("all");
customerSuggestions.value = customers.filter((name) =>
name.toLowerCase().includes(event.query.toLowerCase()),
);
} catch (error) {
console.error("Error searching customers:", error);
customerSuggestions.value = [];
}
};
const onCustomerSelect = (event) => {
// Store selected customer for contact lookup
selectedCustomerData.value = event.value;
// Reset contact data when customer changes
formData.value.contactName = "";
formData.value.phoneNumber = "";
formData.value.email = "";
};
// Contact search
const searchContacts = async (event) => {
if (!selectedCustomerData.value) {
contactSuggestions.value = [];
return;
}
try {
// TODO: Implement contact search API method
// For now, just allow typing
contactSuggestions.value = [event.query];
} catch (error) {
console.error("Error searching contacts:", error);
contactSuggestions.value = [];
}
};
const onContactSelect = (event) => {
// TODO: Auto-populate phone and email from selected contact
// For now, just set the name
formData.value.contactName = event.value;
};
// Save/Cancel actions
const handleSave = async () => {
if (!isFormValid.value) {
notificationStore.addError("Please fill in all required fields");
return;
}
isSubmitting.value = true;
try {
if (props.isNew) {
// Create new client
const clientData = {
customerName: formData.value.customerName,
customerType: formData.value.customerType,
addressLine1: formData.value.addressLine1,
addressLine2: formData.value.addressLine2,
zipcode: formData.value.zipcode,
city: formData.value.city,
state: formData.value.state,
contactName: formData.value.contactName,
phoneNumber: formData.value.phoneNumber,
email: formData.value.email,
};
// TODO: Implement API call to create client
console.log("Would create client with data:", clientData);
// For now, just show success and redirect
const fullAddress = DataUtils.calculateFullAddress({
addressLine1: formData.value.addressLine1,
addressLine2: formData.value.addressLine2,
city: formData.value.city,
state: formData.value.state,
pincode: formData.value.zipcode,
});
notificationStore.addSuccess(
`Client ${formData.value.customerName} created successfully!`,
);
// Redirect to the new client page
await router.push({
path: "/client",
query: {
client: formData.value.customerName,
address: fullAddress,
},
});
} else {
// Update existing client (edit mode)
// TODO: Implement API call to update client
console.log("Would update client with data:", formData.value);
notificationStore.addSuccess("Client updated successfully!");
editMode.value = false;
}
} catch (error) {
console.error("Error saving client:", error);
notificationStore.addError("Failed to save client information");
} finally {
isSubmitting.value = false;
}
};
const handleCancel = () => {
if (props.isNew) {
// Go back for new client
router.back();
} else {
// Exit edit mode and restore original data
editMode.value = false;
populateFormFromClientData();
}
};
</script>
<style scoped>
.overview-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
.info-card,
.map-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-header h3 {
margin: 0;
}
.info-card h3,
.map-card h3 {
margin: 0 0 1rem 0;
color: var(--text-color);
font-size: 1.25rem;
font-weight: 600;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item.full-width {
grid-column: 1 / -1;
}
.info-item label {
font-weight: 500;
color: var(--text-color-secondary);
font-size: 0.9rem;
}
.info-item span {
color: var(--text-color);
font-size: 0.95rem;
}
/* Form input styling */
.info-item :deep(.p-inputtext),
.info-item :deep(.p-autocomplete),
.info-item :deep(.p-select) {
width: 100%;
}
.info-item :deep(.p-autocomplete .p-inputtext) {
width: 100%;
}
/* Required field indicator */
.info-item label:has(+ .p-inputtext[required])::after,
.info-item label:has(+ .p-autocomplete)::after {
content: " *";
color: var(--red-500);
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.status-card {
background: var(--surface-card);
border-radius: 8px;
padding: 1rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
}
.status-card h4 {
margin: 0;
font-size: 1rem;
font-weight: 500;
color: var(--text-color);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-card);
border-radius: 8px;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.coordinates-info {
margin-top: 0.75rem;
text-align: center;
color: var(--text-color-secondary);
padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
.confirm-dialog {
max-width: 400px;
}
.confirm-dialog :deep(.p-dialog-footer) {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
/* Utilities */
.w-full {
width: 100% !important;
}
@media (max-width: 768px) {
.overview-container {
padding: 0.5rem;
gap: 1rem;
}
.info-card,
.map-card {
padding: 1rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.status-cards {
grid-template-columns: repeat(2, 1fr);
}
.form-actions {
padding: 1rem;
flex-direction: column;
}
.card-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
}
@media (max-width: 480px) {
.status-cards {
grid-template-columns: 1fr;
}
}
</style>