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

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

View File

@ -194,7 +194,8 @@ def get_clients_table_data(filters={}, sortings=[], page=1, page_size=10):
})
processed_filters, processed_sortings, is_or, page, page_size = process_query_conditions(filters, sortings, page, page_size)
print("DEBUG: Processed filters:", processed_filters)
print("DEBUG: Processed sortings:", processed_sortings)
# Handle count with proper OR filter support
if is_or:
count = frappe.db.sql(*get_count_or_filters("Address", processed_filters))[0][0]

View File

@ -1,8 +1,9 @@
import frappe
import requests
from urllib.parse import urlparse
import logging
allowed_hosts = ["api.zippopotam.us"] # Update this list with trusted domains as needed
allowed_hosts = ["api.zippopotam.us", "nominatim.openstreetmap.org"] # Update this list with trusted domains as needed
@frappe.whitelist(allow_guest=True)
def request(url, method="GET", data=None, headers=None):

View File

@ -5,11 +5,21 @@ from .utils import create_module
def after_install():
create_module()
add_custom_fields()
build_frontend()
def after_migrate():
add_custom_fields()
frappe.db.commit()
# Proper way to refresh metadata
frappe.clear_cache(doctype="Address")
frappe.reload_doctype("Address")
update_address_fields()
build_frontend()
def build_frontend():
app_package_path = frappe.get_app_path("custom_ui")
app_root = os.path.dirname(app_package_path)
@ -40,4 +50,231 @@ def build_frontend():
print("\n✅ Frontend build completed successfully.\n")
except subprocess.CalledProcessError as e:
frappe.log_error(message=str(e), title="Frontend Build Failed")
print(f"\n❌ Frontend build failed: {e}\n")
print(f"\n❌ Frontend build failed: {e}\n")
def add_custom_fields():
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
print("\n🔧 Adding custom fields to Address doctype...")
custom_fields = {
"Address": [
dict(
fieldname="full_address",
label="Full Address",
fieldtype="Data",
insert_after="country"
),
dict(
fieldname="latitude",
label="Latitude",
fieldtype="Float",
precision=8,
insert_after="full_address"
),
dict(
fieldname="longitude",
label="Longitude",
fieldtype="Float",
precision=8,
insert_after="latitude"
),
dict(
fieldname="onsite_meeting_scheduled",
label="On-Site Meeting Scheduled",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="longitude"
),
dict(
fieldname="estimate_sent_status",
label="Estimate Sent Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="onsite_meeting_scheduled"
),
dict(
fieldname="job_status",
label="Job Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="estimate_sent_status"
),
dict(
fieldname="payment_received_status",
label="Payment Received Status",
fieldtype="Select",
options="Not Started\nIn Progress\nCompleted",
default="Not Started",
insert_after="job_status"
)
]
}
field_count = len(custom_fields["Address"])
print(f"📝 Creating {field_count} custom fields for Address doctype...")
try:
create_custom_fields(custom_fields)
print("✅ Custom fields added successfully!")
print(" • full_address (Data)")
print(" • latitude (Float)")
print(" • longitude (Float)")
print(" • onsite_meeting_scheduled (Select)")
print(" • estimate_sent_status (Select)")
print(" • job_status (Select)")
print(" • payment_received_status (Select)")
print("🔧 Custom fields installation complete.\n")
except Exception as e:
print(f"❌ Error creating custom fields: {str(e)}")
frappe.log_error(message=str(e), title="Custom Fields Creation Failed")
raise
def update_address_fields():
addresses = frappe.get_all("Address", pluck="name")
total_addresses = len(addresses)
if total_addresses == 0:
print("📍 No addresses found to update.")
return
print(f"\n📍 Updating fields for {total_addresses} addresses...")
# Verify custom fields exist by checking the meta
address_meta = frappe.get_meta("Address")
required_fields = ['full_address', 'custom_onsite_meeting_scheduled',
'custom_estimate_sent_status', 'custom_job_status',
'custom_payment_received_status']
missing_fields = []
for field in required_fields:
if not address_meta.has_field(field):
missing_fields.append(field)
if missing_fields:
print(f"\n❌ Missing custom fields: {', '.join(missing_fields)}")
print(" Custom fields creation may have failed. Skipping address updates.")
return
print("✅ All custom fields verified. Proceeding with address updates...")
# Field update counters
field_counters = {
'full_address': 0,
'latitude': 0,
'longitude': 0,
'custom_onsite_meeting_scheduled': 0,
'custom_estimate_sent_status': 0,
'custom_job_status': 0,
'custom_payment_received_status': 0
}
total_field_updates = 0
addresses_updated = 0
for index, name in enumerate(addresses, 1):
# Calculate progress
progress_percentage = int((index / total_addresses) * 100)
bar_length = 30
filled_length = int(bar_length * index // total_addresses)
bar = '' * filled_length + '' * (bar_length - filled_length)
# Print progress bar with field update count
print(f"\r📊 Progress: [{bar}] {progress_percentage:3d}% ({index}/{total_addresses}) | Fields Updated: {total_field_updates} - Processing: {name[:25]}...", end='', flush=True)
should_update = False
address = frappe.get_doc("Address", name)
current_address_updates = 0
current_address_updates = 0
# Use getattr with default values instead of direct attribute access
if not getattr(address, 'full_address', None):
address_parts_1 = [
address.address_line1 or "",
address.address_line2 or "",
address.city or "",
]
address_parts_2 = [
address.state or "",
address.pincode or "",
]
full_address = ", ".join([
" ".join(filter(None, address_parts_1)),
" ".join(filter(None, address_parts_2))
]).strip()
address.full_address = full_address
field_counters['full_address'] += 1
current_address_updates += 1
should_update = True
onsite_meeting = "Not Started"
estimate_sent = "Not Started"
job_status = "Not Started"
payment_received = "Not Started"
onsite_meetings = frappe.get_all("On-Site Meeting", filters={"address": address.address_title})
if onsite_meetings and onsite_meetings[0]:
onsite_meeting = "Completed"
estimates = frappe.get_all("Quotation", fields=["custom_sent"], filters={"custom_installation_address": address.address_title})
if estimates and estimates[0] and estimates[0]["custom_sent"] == 1:
estimate_sent = "Completed"
elif estimates and estimates[0]:
estimate_sent = "In Progress"
jobs = frappe.get_all("Project", fields=["status"], filters={"custom_installation_address": address.address_title, "project_template": "SNW Install"})
if jobs and jobs[0] and jobs[0]["status"] == "Completed":
job_status = "Completed"
elif jobs and jobs[0]:
job_status = "In Progress"
sales_invoices = frappe.get_all("Sales Invoice", fields=["outstanding_amount"], filters={"custom_installation_address": address.address_title})
# payments = frappe.get_all("Payment Entry", filters={"custom_installation_address": address.address_title})
if sales_invoices and sales_invoices[0] and sales_invoices[0]["outstanding_amount"] == 0:
payment_received = "Completed"
elif sales_invoices and sales_invoices[0]:
payment_received = "In Progress"
if getattr(address, 'custom_onsite_meeting_scheduled', None) != onsite_meeting:
address.custom_onsite_meeting_scheduled = onsite_meeting
field_counters['custom_onsite_meeting_scheduled'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_estimate_sent_status', None) != estimate_sent:
address.custom_estimate_sent_status = estimate_sent
field_counters['custom_estimate_sent_status'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_job_status', None) != job_status:
address.custom_job_status = job_status
field_counters['custom_job_status'] += 1
current_address_updates += 1
should_update = True
if getattr(address, 'custom_payment_received_status', None) != payment_received:
address.custom_payment_received_status = payment_received
field_counters['custom_payment_received_status'] += 1
current_address_updates += 1
should_update = True
if should_update:
address.save(ignore_permissions=True)
addresses_updated += 1
total_field_updates += current_address_updates
# Print completion summary
print(f"\n\n✅ Address field update completed!")
print(f"📊 Summary:")
print(f" • Total addresses processed: {total_addresses:,}")
print(f" • Addresses updated: {addresses_updated:,}")
print(f" • Total field updates: {total_field_updates:,}")
print(f"\n📝 Field-specific updates:")
print(f" • Full Address: {field_counters['full_address']:,}")
print(f" • Latitude: {field_counters['latitude']:,}")
print(f" • Longitude: {field_counters['longitude']:,}")
print(f" • On-Site Meeting Status: {field_counters['custom_onsite_meeting_scheduled']:,}")
print(f" • Estimate Sent Status: {field_counters['custom_estimate_sent_status']:,}")
print(f" • Job Status: {field_counters['custom_job_status']:,}")
print(f" • Payment Received Status: {field_counters['custom_payment_received_status']:,}")
print("📍 Address field updates complete.\n")

View File

@ -14,10 +14,12 @@
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"frappe-ui": "^0.1.205",
"leaflet": "^1.9.4",
"pinia": "^3.0.3",
"primevue": "^4.4.1",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-leaflet": "^0.1.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7"
},
@ -3214,6 +3216,12 @@
"integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
"license": "MIT"
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -4626,6 +4634,12 @@
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-leaflet": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/vue-leaflet/-/vue-leaflet-0.1.0.tgz",
"integrity": "sha512-J2QxmQSbmnpM/Ng+C8vxowXcWp/IEe99r87psHyWYpBz2nbxkQAeYXW7WFcgzV4O7d7Vm4a1GcqKzrU9DeDpBA==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",

View File

@ -15,10 +15,12 @@
"axios": "^1.12.2",
"chart.js": "^4.5.1",
"frappe-ui": "^0.1.205",
"leaflet": "^1.9.4",
"pinia": "^3.0.3",
"primevue": "^4.4.1",
"vue": "^3.5.22",
"vue-chartjs": "^5.3.3",
"vue-leaflet": "^0.1.0",
"vue-router": "^4.6.3",
"vuetify": "^3.10.7"
},

View File

@ -250,6 +250,10 @@ class Api {
return await this.request(FRAPPE_GET_CLIENT_NAMES_METHOD, { type });
}
static async getClientNames(type) {
return await this.request(FRAPPE_GET_CLIENT_NAMES_METHOD, { type });
}
static async getCompanyNames() {
const companies = await this.getDocsList("Company", ["name"]);
const companyNames = companies.map((company) => company.name);
@ -311,6 +315,18 @@ class Api {
}
return places;
}
static async getGeocode(address) {
const urlSafeAddress = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&q=${urlSafeAddress}`;
const response = await this.request(FRAPPE_PROXY_METHOD, {
url,
method: "GET",
headers: { "User-Agent": "FrappeApp/1.0 (+https://yourappdomain.com)" },
});
const { lat, lon } = response[0] || {};
return { latitude: parseFloat(lat), longitude: parseFloat(lon) };
}
}
export default Api;

View File

@ -31,7 +31,7 @@ const createButtons = ref([
{
label: "Client",
command: () => {
router.push("/clients/new");
router.push("/client?new=true");
},
},
{

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>

View File

@ -0,0 +1,5 @@
<template lang="">
<div></div>
</template>
<script setup></script>
<style lang=""></style>

View File

@ -1052,7 +1052,7 @@ const getBadgeColor = (status) => {
return "success";
case "in progress":
case "pending":
return "warning";
return "warn";
case "not started":
case "closed":
case "cancelled":

View File

@ -0,0 +1,219 @@
<template>
<div class="map-container">
<div ref="mapElement" class="map" :style="{ height: mapHeight }"></div>
<div v-if="!latitude || !longitude" class="map-overlay">
<div class="no-coordinates">
<i
class="pi pi-map-marker"
style="font-size: 2rem; color: #64748b; margin-bottom: 0.5rem"
></i>
<p>No coordinates available</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from "vue";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Fix Leaflet default marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png",
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png",
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png",
});
const props = defineProps({
latitude: {
type: [Number, String],
required: false,
default: null,
},
longitude: {
type: [Number, String],
required: false,
default: null,
},
addressTitle: {
type: String,
default: "Location",
},
mapHeight: {
type: String,
default: "400px",
},
zoomLevel: {
type: Number,
default: 15,
},
interactive: {
type: Boolean,
default: true,
},
});
const mapElement = ref(null);
let map = null;
let marker = null;
const initializeMap = async () => {
if (!mapElement.value) return;
// Clean up existing map
if (map) {
map.remove();
map = null;
marker = null;
}
const lat = parseFloat(props.latitude);
const lng = parseFloat(props.longitude);
// Only create map if we have valid coordinates
if (!isNaN(lat) && !isNaN(lng)) {
await nextTick();
// Initialize map
map = L.map(mapElement.value, {
zoomControl: props.interactive,
dragging: props.interactive,
touchZoom: props.interactive,
scrollWheelZoom: props.interactive,
doubleClickZoom: props.interactive,
boxZoom: props.interactive,
keyboard: props.interactive,
}).setView([lat, lng], props.zoomLevel);
// Add tile layer
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "© OpenStreetMap contributors",
}).addTo(map);
// Add marker
marker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`,
)
.openPopup();
}
};
const updateMap = () => {
const lat = parseFloat(props.latitude);
const lng = parseFloat(props.longitude);
if (map && !isNaN(lat) && !isNaN(lng)) {
// Update map view
map.setView([lat, lng], props.zoomLevel);
// Update marker
if (marker) {
marker.setLatLng([lat, lng]);
marker.setPopupContent(`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`);
} else {
marker = L.marker([lat, lng])
.addTo(map)
.bindPopup(
`
<div style="text-align: center;">
<strong>${props.addressTitle}</strong><br>
<small>Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}</small>
</div>
`,
)
.openPopup();
}
} else if (!isNaN(lat) && !isNaN(lng)) {
// Coordinates available but no map yet - initialize
initializeMap();
}
};
// Watch for coordinate changes
watch(
() => [props.latitude, props.longitude, props.addressTitle],
() => {
updateMap();
},
{ immediate: false },
);
onMounted(() => {
initializeMap();
});
onUnmounted(() => {
if (map) {
map.remove();
map = null;
marker = null;
}
});
</script>
<style scoped>
.map-container {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.map {
width: 100%;
z-index: 1;
}
.map-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--surface-ground);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.no-coordinates {
text-align: center;
color: var(--text-color-secondary);
padding: 2rem;
}
.no-coordinates p {
margin: 0;
font-size: 0.9rem;
}
/* Leaflet popup customization */
:deep(.leaflet-popup-content-wrapper) {
border-radius: 6px;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
:deep(.leaflet-popup-tip) {
background: white;
border: none;
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.4);
}
</style>

View File

@ -1,44 +1,89 @@
<template>
<!-- Client Header -->
<div class="client-header" v-if="client.customerName">
<div class="client-info">
<h2 class="client-name">{{ client.customerName }}</h2>
<div class="address-section" v-if="addresses.length > 0">
<label class="address-label">Address:</label>
<Select
v-if="addresses.length > 1"
v-model="selectedAddress"
:options="addresses"
class="address-dropdown"
placeholder="Select an address"
/>
<span v-else class="single-address">{{ addresses[0] }}</span>
</div>
</div>
</div>
<Tabs value="0">
<TabList>
<Tab value="0">Overview</Tab>
<Tab value="1">Projects <span class=tab-info-alert>1</span></Tab>
<Tab value="1">Projects <span class="tab-info-alert">1</span></Tab>
<Tab value="2">Financials</Tab>
<Tab value="3">History</Tab>
</TabList>
<TabPanels>
<TabPanel value="0">
<div id=overview-tab><h3>Overview</h3></div>
<Overview
:client-data="client"
:selected-address="selectedAddress"
:is-new="isNew"
/>
</TabPanel>
<TabPanel value="1">
<div id=projects-tab><h3>Project Status</h3></div>
<div id="projects-tab"><h3>Project Status</h3></div>
</TabPanel>
<TabPanel value="2">
<div id=financials-tab><h3>Accounting</h3></div>
<div id="financials-tab"><h3>Accounting</h3></div>
</TabPanel>
<TabPanel value="3">
<div id=history-tab><h3>History</h3></div>
<div id="history-tab"><h3>History</h3></div>
</TabPanel>
</TabPanels>
</Tabs>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
import Select from "primevue/select";
import Api from "../../api";
import ApiWithToast from "../../api-toast";
import { useRoute } from "vue-router";
import { useLoadingStore } from "../../stores/loading";
import { useNotificationStore } from "../../stores/notifications-primevue";
import DataUtils from "../../utils";
import Overview from "../clientSubPages/Overview.vue";
import ProjectStatus from "../clientSubPages/ProjectStatus.vue";
const route = useRoute();
const loadingStore = useLoadingStore();
const notificationStore = useNotificationStore();
const address = route.query.address || null;
const clientName = route.query.client || null;
const isNew = computed(() => route.query.new === "true" || false);
const clientNames = ref([]);
const client = ref({});
const { clientName } = defineProps({
clientName: { type: String, required: true },
const geocode = ref({});
const selectedAddress = ref(address);
const selectedAddressObject = computed(() =>
client.value.addresses?.find(
(addr) => DataUtils.calculateFullAddress(addr) === selectedAddress.value,
),
);
const addresses = computed(() => {
if (client.value && client.value.addresses) {
return client.value.addresses.map((addr) => DataUtils.calculateFullAddress(addr));
}
return [];
});
const getClientNames = async (type) => {
@ -58,6 +103,30 @@ const getClient = async (name) => {
try {
const clientData = await Api.getClient(name);
client.value = clientData || {};
// Set initial selected address if provided in route or use first address
if (address && client.value.addresses) {
const fullAddresses = client.value.addresses.map((addr) =>
DataUtils.calculateFullAddress(addr),
);
if (fullAddresses.includes(address)) {
selectedAddress.value = address;
} else if (fullAddresses.length > 0) {
selectedAddress.value = fullAddresses[0];
}
} else if (client.value.addresses && client.value.addresses.length > 0) {
selectedAddress.value = DataUtils.calculateFullAddress(client.value.addresses[0]);
}
if (
selectedAddressObject.value?.customLongitude &&
selectedAddressObject.value?.customLatitude
) {
geocode.value = {
longitude: selectedAddressObject.value.customLongitude,
latitude: selectedAddressObject.value.customLatitude,
};
} else if (selectedAddress.value) {
geocode.value = await Api.getGeocode(selectedAddress.value);
}
} catch (error) {
console.error("Error fetching client data in Client.vue: ", error.message || error);
} finally {
@ -66,18 +135,99 @@ const getClient = async (name) => {
};
onMounted(async () => {
if (clientName === "new") {
// Logic for creating a new client
console.log("Creating a new client");
} else {
// Logic for fetching and displaying existing client data
if (clientName) {
await getClient(clientName);
console.log("Displaying existing client data");
}
console.debug(
"DEBUG: Client.vue mounted with clientName:",
clientName,
"isNew:",
isNew.value,
"address:",
address,
"addresses:",
addresses.value,
"selectedAddress:",
selectedAddress.value,
"Does selected address match an address in addresses?:",
selectedAddress.value && addresses.value.includes(selectedAddress.value),
"geocode:",
geocode.value,
);
});
watch(
() => route.query,
async (newQuery, oldQuery) => {
const clientName = newQuery.client || null;
const isNewClient = newQuery.new === "true" || false;
const address = newQuery.address || null;
// Clear client data if switching to new client mode
if (isNewClient) {
client.value = {};
selectedAddress.value = null;
geocode.value = {};
console.log("Switched to new client mode - cleared client data");
} else if (clientName && clientName !== oldQuery.client) {
// Load client data if switching to existing client
await getClient(clientName);
console.log("Route query changed - displaying existing client data");
}
},
);
</script>
<style lang="css">
.client-header {
background: var(--surface-card);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid var(--surface-border);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.client-info {
display: flex;
flex-direction: column;
gap: 1rem;
}
.client-name {
margin: 0;
color: var(--text-color);
font-size: 1.75rem;
font-weight: 600;
}
.address-section {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.address-label {
font-weight: 500;
color: var(--text-color-secondary);
min-width: 70px;
}
.address-dropdown {
min-width: 300px;
flex: 1;
max-width: 500px;
}
.single-address {
color: var(--text-color);
font-size: 0.95rem;
padding: 0.5rem 0;
}
.tab-info-alert {
background-color: a95e46;
background-color: #a95e46;
border-radius: 10px;
color: white;
padding-left: 5px;
@ -85,4 +235,25 @@ onMounted(async () => {
padding-top: 2px;
padding-bottom: 2px;
}
@media (max-width: 768px) {
.client-info {
gap: 0.75rem;
}
.client-name {
font-size: 1.5rem;
}
.address-section {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.address-dropdown {
min-width: 100%;
max-width: 100%;
}
}
</style>

View File

@ -165,7 +165,7 @@ const tableActions = [
{
label: "View Details",
action: (rowData) => {
router.push(`/clients/${rowData.customerName}`);
router.push(`/client?client=${rowData.customerName}&address=${rowData.address}`);
},
type: "button",
style: "info",
@ -191,36 +191,36 @@ const tableActions = [
// variant: "filled",
// },
// },
{
label: "Edit",
action: (rowData) => {
console.log("Editing client:", rowData);
// Implementation would open edit modal
},
type: "button",
style: "secondary",
icon: "pi pi-pencil",
rowAction: true, // Row action - appears in each row's actions column
layout: {
priority: "primary",
variant: "outlined",
},
},
{
label: "Quick View",
action: (rowData) => {
console.log("Quick view for:", rowData.addressTitle);
// Implementation would show quick preview
},
type: "button",
style: "info",
icon: "pi pi-search",
rowAction: true, // Row action - appears in each row's actions column
layout: {
priority: "secondary",
variant: "compact",
},
},
// {
// label: "Edit",
// action: (rowData) => {
// console.log("Editing client:", rowData);
// // Implementation would open edit modal
// },
// type: "button",
// style: "secondary",
// icon: "pi pi-pencil",
// rowAction: true, // Row action - appears in each row's actions column
// layout: {
// priority: "primary",
// variant: "outlined",
// },
// },
// {
// label: "Quick View",
// action: (rowData) => {
// console.log("Quick view for:", rowData.addressTitle);
// // Implementation would show quick preview
// },
// type: "button",
// style: "info",
// icon: "pi pi-search",
// rowAction: true, // Row action - appears in each row's actions column
// layout: {
// priority: "secondary",
// variant: "compact",
// },
// },
];
// Handle lazy loading events from DataTable
const handleLazyLoad = async (event) => {

View File

@ -19,7 +19,7 @@ const routes = [
},
{ path: "/calendar", component: Calendar },
{ path: "/clients", component: Clients },
{ path: "/clients/:clientName", component: Client, props: true },
{ path: "/client", component: Client },
{ path: "/jobs", component: Jobs },
{ path: "/routes", component: Routes },
{ path: "/create", component: Create },

File diff suppressed because it is too large Load Diff