update create client form

This commit is contained in:
Casey Wittrock 2025-11-03 01:54:55 -06:00
parent 1f33262e90
commit 2cfe7ed8e6
9 changed files with 1856 additions and 1412 deletions

2
.gitignore vendored
View File

@ -5,6 +5,8 @@
tags tags
node_modules node_modules
__pycache__ __pycache__
venv/
.venv/
*dist/ *dist/
.vscode/ .vscode/

View File

@ -5,28 +5,27 @@ from urllib.parse import urlparse
allowed_hosts = ["api.zippopotam.us"] # Update this list with trusted domains as needed allowed_hosts = ["api.zippopotam.us"] # Update this list with trusted domains as needed
@frappe.whitelist(allow_guest=True) @frappe.whitelist(allow_guest=True)
def proxy_request(url, method="GET", data=None, headers=None): def request(url, method="GET", data=None, headers=None):
""" """
Generic proxy for external API requests. Generic proxy for external API requests.
WARNING: Only allow requests to trusted domains. WARNING: Only allow requests to trusted domains.
""" """
parsed_url = urlparse(url) parsed_url = urlparse(url)
if parsed_url.hostname not in allowed_hosts: if parsed_url.hostname not in allowed_hosts:
frappe.throw(f"Rquests to {parsed_url.hostname} are not allowed.", frappe.PermissionError) frappe.throw(f"Requests to {parsed_url.hostname} are not allowed.", frappe.PermissionError)
try:
resp = requests.request(
method=method.upper(),
url=url,
json=frappe.parse_json(data) if data else None,
headers=frappe.parse_json(headers) if headers else None,
timeout=10
)
resp.raise_for_status()
try: try:
resp = requests.request( return resp.json()
method=method.upper(), except ValueError:
url=url, return {"text": resp.text}
json=frappe.parse_json(data) if data else None, except requests.exceptions.RequestException as e:
headers=frappe.parse_json(headers) if headers else None, frappe.log_error(message=str(e), title="Proxy Request Failed")
timeout=10 frappe.throw("Failed to fetch data from external API.")
)
resp.raise_for_status()
try:
return resp.json()
except ValueError:
return {"text": resp.text}
except requests.exceptions.RequestException as e:
frappe.log_error(message=str(e), title="Proxy Request Failed")
frappe.throw("Failed to fetch data from external API.")

File diff suppressed because it is too large Load Diff

View File

@ -35,10 +35,10 @@ import ScrollPanel from "primevue/scrollpanel";
border-radius: 10px; border-radius: 10px;
padding: 10px; padding: 10px;
border: 4px solid rgb(235, 230, 230); border: 4px solid rgb(235, 230, 230);
max-width: 1280px; max-width: 2500px;
min-width: 800px; width: 100%;
margin: 10px auto; margin: 10px auto;
min-height: 87vh; height: 83vh;
} }
#display-content { #display-content {
@ -47,6 +47,6 @@ import ScrollPanel from "primevue/scrollpanel";
margin-right: auto; margin-right: auto;
max-width: 50vw; max-width: 50vw;
min-width: 80%; min-width: 80%;
max-height: 87vh; height: 100%;
} }
</style> </style>

View File

@ -1,25 +1,20 @@
import DataUtils from "./utils"; import DataUtils from "./utils";
import axios from "axios";
const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us"; const ZIPPOPOTAMUS_BASE_URL = "https://api.zippopotam.us/us";
class Api { class Api {
static async request(url, method = "GET", data = {}) {
static async request(url, method = "get", data = {}) {
try { try {
const response = await axios({ const response = await frappe.call({
url, method: "custom_ui.api.proxy.request",
method, args: {
data, url,
withCredentials: false, method,
timeout: 10000, // 10 second timeout data: JSON.stringify(data),
headers: { },
'Accept': 'application/json', });
'Content-Type': 'application/json' console.log("DEBUG: API - Request Response: ", response);
} return response.message;
})
console.log("DEBUG: API - Request Response: ", response.data);
return response.data;
} catch (error) { } catch (error) {
console.error("DEBUG: API - Request Error: ", error); console.error("DEBUG: API - Request Error: ", error);
// Re-throw the error so calling code can handle it // Re-throw the error so calling code can handle it
@ -80,8 +75,8 @@ class Api {
/** /**
* Fetch a list of documents from a specific doctype. * Fetch a list of documents from a specific doctype.
* *
* @param {String} doctype * @param {String} doctype
* @param {string[]} fields * @param {string[]} fields
* @returns {Promise<Object[]>} * @returns {Promise<Object[]>}
*/ */
static async getDocsList(doctype, fields = []) { static async getDocsList(doctype, fields = []) {
@ -93,8 +88,8 @@ class Api {
/** /**
* Fetch a detailed document by doctype and name. * Fetch a detailed document by doctype and name.
* *
* @param {String} doctype * @param {String} doctype
* @param {String} name * @param {String} name
* @returns {Promise<Object>} * @returns {Promise<Object>}
*/ */
static async getDetailedDoc(doctype, name) { static async getDetailedDoc(doctype, name) {
@ -105,36 +100,27 @@ class Api {
/** /**
* Fetch a list of places (city/state) by zipcode using Zippopotamus API. * Fetch a list of places (city/state) by zipcode using Zippopotamus API.
* *
* @param {String} zipcode * @param {String} zipcode
* @returns {Promise<Object[]>} * @returns {Promise<Object[]>}
*/ */
static async getCityStateByZip(zipcode) { static async getCityStateByZip(zipcode) {
const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`; const url = `${ZIPPOPOTAMUS_BASE_URL}/${zipcode}`;
try { const response = await this.request(url);
const response = await this.request(url); const { places } = response || {};
const { places } = response || {}; if (!places || places.length === 0) {
throw new Error(`No location data found for zip code ${zipcode}`);
if (!places || places.length === 0) {
throw new Error(`No location data found for zip code ${zipcode}`);
}
return places;
} catch (error) {
console.error("DEBUG: API - getCityStateByZip Error: ", error);
// Provide more specific error information
if (error.code === 'ERR_NETWORK') {
throw new Error('Network error: Unable to connect to location service. This may be due to CORS restrictions or network connectivity issues.');
} else if (error.response?.status === 404) {
throw new Error(`Zip code ${zipcode} not found in database.`);
} else if (error.code === 'ECONNABORTED') {
throw new Error('Request timeout: Location service is taking too long to respond.');
}
// Re-throw the original error if we can't categorize it
throw error;
} }
return places;
}
/**
* Fetch a list of Customer names.
* @returns {Promise<String[]>}
*/
static async getCustomerNames() {
const customers = await this.getDocsList("Customer", ["name"]);
return customers.map((customer) => customer.name);
} }
} }

View File

@ -1,336 +0,0 @@
<!-- Modal Usage Examples -->
<template>
<div class="modal-examples">
<h2>Modal Component Examples</h2>
<!-- Example buttons to trigger different modal types -->
<div class="example-buttons">
<v-btn @click="showBasicModal" color="primary">Basic Modal</v-btn>
<v-btn @click="showFormModal" color="secondary">Form Modal</v-btn>
<v-btn @click="showConfirmModal" color="warning">Confirmation Modal</v-btn>
<v-btn @click="showFullscreenModal" color="success">Fullscreen Modal</v-btn>
<v-btn @click="showCustomModal" color="info">Custom Styled Modal</v-btn>
</div>
<!-- Basic Modal -->
<Modal
v-model:visible="basicModalVisible"
:options="basicModalOptions"
@close="onBasicModalClose"
@confirm="onBasicModalConfirm"
>
<p>This is a basic modal with default settings.</p>
<p>You can put any content here!</p>
</Modal>
<!-- Form Modal -->
<Modal
v-model:visible="formModalVisible"
:options="formModalOptions"
@close="onFormModalClose"
@confirm="onFormModalConfirm"
>
<template #title>
<v-icon class="mr-2">mdi-account-plus</v-icon>
Add New User
</template>
<v-form ref="userForm" v-model="formValid">
<v-text-field
v-model="userForm.name"
label="Full Name"
:rules="[v => !!v || 'Name is required']"
required
/>
<v-text-field
v-model="userForm.email"
label="Email"
type="email"
:rules="emailRules"
required
/>
<v-select
v-model="userForm.role"
:items="roleOptions"
label="Role"
:rules="[v => !!v || 'Role is required']"
required
/>
</v-form>
</Modal>
<!-- Confirmation Modal -->
<Modal
v-model:visible="confirmModalVisible"
:options="confirmModalOptions"
@confirm="onDeleteConfirm"
@cancel="onDeleteCancel"
>
<div class="text-center">
<v-icon size="64" color="warning" class="mb-4">mdi-alert-circle</v-icon>
<h3 class="mb-2">Are you sure?</h3>
<p>This action cannot be undone. The item will be permanently deleted.</p>
</div>
</Modal>
<!-- Fullscreen Modal -->
<Modal
v-model:visible="fullscreenModalVisible"
:options="fullscreenModalOptions"
@close="onFullscreenModalClose"
>
<template #title>
Fullscreen Content
</template>
<div class="fullscreen-content">
<v-row>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Left Panel</v-card-title>
<v-card-text>
<p>This is a fullscreen modal that can contain complex layouts.</p>
<v-list>
<v-list-item v-for="i in 10" :key="i">
<v-list-item-title>Item {{ i }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card>
<v-card-title>Right Panel</v-card-title>
<v-card-text>
<v-img
src="https://picsum.photos/400/200"
height="200"
class="mb-4"
/>
<p>You can include any Vue components here.</p>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</Modal>
<!-- Custom Styled Modal -->
<Modal
v-model:visible="customModalVisible"
:options="customModalOptions"
@close="onCustomModalClose"
>
<template #title>
<div class="custom-title">
<v-icon class="mr-2">mdi-palette</v-icon>
Custom Styled Modal
</div>
</template>
<div class="custom-content">
<v-card variant="outlined" class="mb-4">
<v-card-text>
<v-icon size="32" color="primary" class="mr-2">mdi-information</v-icon>
This modal demonstrates custom styling options.
</v-card-text>
</v-card>
<v-timeline density="compact">
<v-timeline-item
v-for="item in timelineItems"
:key="item.id"
:dot-color="item.color"
size="small"
>
<v-card>
<v-card-title>{{ item.title }}</v-card-title>
<v-card-subtitle>{{ item.time }}</v-card-subtitle>
</v-card>
</v-timeline-item>
</v-timeline>
</div>
<template #actions="{ close }">
<v-btn color="gradient" variant="elevated" @click="close">
<v-icon class="mr-1">mdi-check</v-icon>
Got it!
</v-btn>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import Modal from './common/Modal.vue'
// Basic Modal
const basicModalVisible = ref(false)
const basicModalOptions = {
title: 'Basic Modal',
maxWidth: '400px',
showActions: true
}
// Form Modal
const formModalVisible = ref(false)
const formValid = ref(false)
const userForm = reactive({
name: '',
email: '',
role: ''
})
const formModalOptions = {
maxWidth: '500px',
persistent: true,
confirmButtonText: 'Save User',
confirmButtonColor: 'success',
loading: false
}
const roleOptions = ['Admin', 'User', 'Manager', 'Viewer']
const emailRules = [
v => !!v || 'Email is required',
v => /.+@.+\..+/.test(v) || 'Email must be valid'
]
// Confirmation Modal
const confirmModalVisible = ref(false)
const confirmModalOptions = {
title: 'Confirm Deletion',
maxWidth: '400px',
persistent: false,
confirmButtonText: 'Delete',
confirmButtonColor: 'error',
cancelButtonText: 'Keep',
cardColor: 'surface-variant'
}
// Fullscreen Modal
const fullscreenModalVisible = ref(false)
const fullscreenModalOptions = {
fullscreen: true,
showActions: false,
scrollable: true
}
// Custom Modal
const customModalVisible = ref(false)
const customModalOptions = {
maxWidth: '600px',
cardColor: 'primary',
cardVariant: 'elevated',
elevation: 12,
headerClass: 'custom-header',
contentClass: 'custom-content-class',
showActions: false,
overlayOpacity: 0.8,
transition: 'scale-transition'
}
const timelineItems = [
{ id: 1, title: 'Project Started', time: '2 hours ago', color: 'primary' },
{ id: 2, title: 'First Milestone', time: '1 hour ago', color: 'success' },
{ id: 3, title: 'Review Phase', time: '30 minutes ago', color: 'warning' }
]
// Modal event handlers
const showBasicModal = () => {
basicModalVisible.value = true
}
const onBasicModalClose = () => {
console.log('Basic modal closed')
}
const onBasicModalConfirm = () => {
console.log('Basic modal confirmed')
}
const showFormModal = () => {
formModalVisible.value = true
}
const onFormModalClose = () => {
// Reset form
Object.assign(userForm, { name: '', email: '', role: '' })
}
const onFormModalConfirm = async () => {
if (formValid.value) {
formModalOptions.loading = true
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
console.log('User saved:', userForm)
formModalOptions.loading = false
}
}
const showConfirmModal = () => {
confirmModalVisible.value = true
}
const onDeleteConfirm = () => {
console.log('Item deleted')
}
const onDeleteCancel = () => {
console.log('Deletion cancelled')
}
const showFullscreenModal = () => {
fullscreenModalVisible.value = true
}
const onFullscreenModalClose = () => {
console.log('Fullscreen modal closed')
}
const showCustomModal = () => {
customModalVisible.value = true
}
const onCustomModalClose = () => {
console.log('Custom modal closed')
}
</script>
<style scoped>
.modal-examples {
padding: 20px;
}
.example-buttons {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.fullscreen-content {
height: 100%;
}
.custom-title {
display: flex;
align-items: center;
color: white;
}
.custom-content {
background: linear-gradient(45deg, #f3f4f6 0%, #ffffff 100%);
padding: 16px;
border-radius: 8px;
}
.custom-header {
background: linear-gradient(45deg, #1976d2 0%, #42a5f5 100%);
color: white;
}
.custom-content-class {
background-color: #fafafa;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -1,413 +1,473 @@
<template> <template>
<Modal <Modal
:visible="isVisible" :visible="isVisible"
:options="modalOptions" :options="modalOptions"
@update:visible="handleVisibilityChange" @update:visible="handleVisibilityChange"
@close="handleClose" @close="handleClose"
> >
<template #title> <template #title> Create New Client </template>
Create New Client
</template> <!-- Status Message -->
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`">
<!-- Status Message --> <i :class="getStatusIcon(statusType)" class="status-icon"></i>
<div v-if="statusMessage" class="status-message" :class="`status-${statusType}`"> {{ statusMessage }}
<v-icon </div>
:icon="statusType === 'warning' ? 'mdi-alert' : statusType === 'error' ? 'mdi-alert-circle' : 'mdi-information'"
size="small" <Form
class="mr-2" :fields="formFields"
/> :form-data="formData"
{{ statusMessage }} :show-cancel-button="true"
</div> :validate-on-change="false"
:validate-on-blur="true"
<Form :validate-on-submit="true"
:fields="formFields" submit-button-text="Create Client"
:form-data="formData" cancel-button-text="Cancel"
:on-submit="handleSubmit" @submit="handleSubmit"
:show-cancel-button="true" @cancel="handleCancel"
:validate-on-change="false" />
:validate-on-blur="true" </Modal>
:validate-on-submit="true"
submit-button-text="Create Client"
cancel-button-text="Cancel"
@submit="handleSubmit"
@cancel="handleCancel"
@change="handleFieldChange"
@blur="handleFieldBlur"
/>
</Modal>
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, watch } from 'vue' import { ref, reactive, computed, watch, watchEffect } from "vue";
import { useModalStore } from '@/stores/modal' import { useModalStore } from "@/stores/modal";
import Modal from '@/components/common/Modal.vue' import Modal from "@/components/common/Modal.vue";
import Form from '@/components/common/Form.vue' import Form from "@/components/common/Form.vue";
import Api from '@/api' import Api from "@/api";
import DataUtils from "../../utils";
const modalStore = useModalStore() const modalStore = useModalStore();
// Modal visibility computed property // Modal visibility computed property
const isVisible = computed(() => modalStore.isModalOpen('createClient')) const isVisible = computed(() => modalStore.isModalOpen("createClient"));
const customerNames = ref([]);
// Form data // Form data
const formData = reactive({ const formData = reactive({
name: '', name: "",
address: '', address: "",
phone: '', phone: "",
email: '', email: "",
zipcode: '', zipcode: "",
city: '', city: "",
state: '' state: "",
}) });
// Available cities for the selected zipcode // Available cities for the selected zipcode
const availableCities = ref([]) const availableCities = ref([]);
// Loading state for zipcode lookup // Loading state for zipcode lookup
const isLoadingZipcode = ref(false) const isLoadingZipcode = ref(false);
// Status message for user feedback // Status message for user feedback
const statusMessage = ref('') const statusMessage = ref("");
const statusType = ref('info') // 'info', 'warning', 'error', 'success' const statusType = ref("info"); // 'info', 'warning', 'error', 'success'
// US State abbreviations for validation
const US_STATES = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
'DC' // District of Columbia
]
// Modal configuration // Modal configuration
const modalOptions = { const modalOptions = {
maxWidth: '600px', maxWidth: "600px",
persistent: false, persistent: false,
showActions: false, showActions: false,
title: 'Create New Client', title: "Create New Client",
overlayColor: 'rgb(59, 130, 246)', // Blue background overlayColor: "rgb(59, 130, 246)", // Blue background
overlayOpacity: 0.8, overlayOpacity: 0.8,
cardClass: 'create-client-modal', cardClass: "create-client-modal",
closeOnOutsideClick: true, closeOnOutsideClick: true,
closeOnEscape: true closeOnEscape: true,
} };
// Form field definitions // Form field definitions
const formFields = computed(() => [ const formFields = computed(() => [
{ {
name: 'name', name: "name",
label: 'Client Name', label: "Client Name",
type: 'text', type: "autocomplete", // Changed from 'select' to 'autocomplete'
required: true, required: true,
placeholder: 'Enter client name', placeholder: "Type or select client name",
cols: 12, cols: 12,
md: 12 options: customerNames.value, // Direct array of strings
}, forceSelection: false, // Allow custom entries not in the list
{ dropdown: true,
name: 'address', // For string arrays, don't set optionLabel at all
label: 'Address', helpText: "Select an existing client or enter a new client name",
type: 'text', // Let the Form component handle filtering automatically
required: true, },
placeholder: 'Enter street address', {
cols: 12, name: "address",
md: 12 label: "Address",
}, type: "text",
{ required: true,
name: 'phone', placeholder: "Enter street address",
label: 'Phone Number', cols: 12,
type: 'text', md: 12,
required: true, },
placeholder: 'Enter phone number', {
format: 'tel', name: "phone",
cols: 12, label: "Phone Number",
md: 6, type: "text",
validate: (value) => { required: true,
if (value && !/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(value)) { placeholder: "Enter phone number",
return 'Please enter a valid phone number' format: "tel",
} cols: 12,
return null md: 6,
} validate: (value) => {
}, if (value && !/^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/.test(value)) {
{ return "Please enter a valid phone number";
name: 'email', }
label: 'Email Address', return null;
type: 'text', },
required: true, },
placeholder: 'Enter email address', {
format: 'email', name: "email",
cols: 12, label: "Email Address",
md: 6 type: "text",
}, required: true,
{ placeholder: "Enter email address",
name: 'zipcode', format: "email",
label: 'Zip Code', cols: 12,
type: 'text', md: 6,
required: true, },
placeholder: 'Enter zip code', {
cols: 12, name: "zipcode",
md: 4, label: "Zip Code",
onChangeOverride: handleZipcodeChange, type: "text",
validate: (value) => { required: true,
if (value && !/^\d{5}(-\d{4})?$/.test(value)) { placeholder: "Enter zip code",
return 'Please enter a valid zip code' cols: 12,
} md: 4,
return null onChangeOverride: handleZipcodeChange,
} validate: (value) => {
}, if (value && !/^\d{5}(-\d{4})?$/.test(value)) {
{ return "Please enter a valid zip code";
name: 'city', }
label: 'City', return null;
type: availableCities.value.length > 0 ? 'select' : 'text', },
required: true, },
disabled: false, {
placeholder: availableCities.value.length > 0 ? 'Select city' : 'Enter city name', name: "city",
options: availableCities.value.map(place => ({ label: "City",
label: place['place name'], type: availableCities.value.length > 0 ? "select" : "text",
value: place['place name'] required: true,
})), disabled: false,
cols: 12, placeholder: availableCities.value.length > 0 ? "Select city" : "Enter city name",
md: 4, options: availableCities.value.map((place) => ({
helpText: isLoadingZipcode.value label: place["place name"],
? 'Loading cities...' value: place["place name"],
: availableCities.value.length > 0 })),
? 'Select from available cities' cols: 12,
: 'Enter city manually (auto-lookup unavailable)' md: 4,
}, helpText: isLoadingZipcode.value
{ ? "Loading cities..."
name: 'state', : availableCities.value.length > 0
label: 'State', ? "Select from available cities"
type: 'text', : "Enter city manually (auto-lookup unavailable)",
required: true, },
disabled: availableCities.value.length > 0, {
placeholder: availableCities.value.length > 0 ? 'Auto-populated' : 'Enter state (e.g., CA, TX, NY)', name: "state",
cols: 12, label: "State",
md: 4, type: "select",
helpText: availableCities.value.length > 0 options: DataUtils.US_STATES.map((stateAbbr) => ({
? 'Auto-populated from zip code' label: stateAbbr,
: 'Enter state abbreviation manually', value: stateAbbr,
validate: (value) => { })),
// Only validate manually entered states (when API lookup failed) required: true,
if (availableCities.value.length === 0 && value) { disabled: availableCities.value.length > 0,
const upperValue = value.toUpperCase() placeholder:
if (!US_STATES.includes(upperValue)) { availableCities.value.length > 0 ? "Auto-populated" : "Enter state (e.g., CA, TX, NY)",
return 'Please enter a valid US state abbreviation (e.g., CA, TX, NY)' cols: 12,
} md: 4,
// Auto-correct to uppercase helpText:
if (value !== upperValue) { availableCities.value.length > 0
formData.state = upperValue ? "Auto-populated from zip code"
} : "Enter state abbreviation manually",
} validate: (value) => {
return null // Only validate manually entered states (when API lookup failed)
} if (availableCities.value.length === 0 && value) {
} const upperValue = value.toUpperCase();
]) if (!DataUtils.US_STATES.includes(upperValue)) {
return "Please enter a valid US state abbreviation (e.g., CA, TX, NY)";
}
}
return null;
},
},
]);
// Handle zipcode change and API lookup // Handle zipcode change and API lookup
async function handleZipcodeChange(value, fieldName, formData) { async function handleZipcodeChange(value, fieldName, currentFormData) {
if (fieldName === 'zipcode' && value && value.length >= 5) { if (fieldName === "zipcode" && value && value.length >= 5) {
// Only process if it's a valid zipcode format // Only process if it's a valid zipcode format
const zipcode = value.replace(/\D/g, '').substring(0, 5) const zipcode = value.replace(/\D/g, "").substring(0, 5);
if (zipcode.length === 5) { if (zipcode.length === 5) {
isLoadingZipcode.value = true isLoadingZipcode.value = true;
try { try {
const places = await Api.getCityStateByZip(zipcode) const places = await Api.getCityStateByZip(zipcode);
console.log("API response for zipcode", zipcode, ":", places);
if (places && places.length > 0) {
availableCities.value = places if (places && places.length > 0) {
availableCities.value = places;
// Auto-populate state from first result
formData.state = places[0].state // Update the reactive formData directly to ensure reactivity
// Use "state abbreviation" instead of "state" for proper abbreviation format
// If only one city, auto-select it const stateValue = places[0]["state abbreviation"] || places[0].state;
if (places.length === 1) { console.log("Setting state to:", stateValue, "from place object:", places[0]);
formData.city = places[0]['place name'] formData.state = stateValue;
showStatusMessage(`Location found: ${places[0]['place name']}, ${places[0].state}`, 'success')
} else { // If only one city, auto-select it
// Clear city selection if multiple cities if (places.length === 1) {
formData.city = '' formData.city = places[0]["place name"];
showStatusMessage(`Found ${places.length} cities for this zip code. Please select one.`, 'info') showStatusMessage(
} `Location found: ${places[0]["place name"]}, ${places[0]["state abbreviation"] || places[0].state}`,
} else { "success",
// No results found - enable manual entry );
handleApiFailure(formData, 'No location data found for this zip code') } else {
} // Clear city selection if multiple cities
} catch (error) { formData.city = "";
console.error('Error fetching city/state data:', error) showStatusMessage(
`Found ${places.length} cities for this zip code. Please select one.`,
// Check if it's a network/CORS error "info",
if (error.code === 'ERR_NETWORK' || error.message.includes('Network Error')) { );
handleApiFailure(formData, 'Unable to fetch location data. Please enter city and state manually.') }
} else { } else {
handleApiFailure(formData, 'Location lookup failed. Please enter city and state manually.') // No results found - enable manual entry
} handleApiFailure("No location data found for this zip code");
} finally { }
isLoadingZipcode.value = false } catch (error) {
} console.error("Error fetching city/state data:", error);
}
} // Check if it's a network/CORS error
if (error.code === "ERR_NETWORK" || error.message.includes("Network Error")) {
handleApiFailure(
"Unable to fetch location data. Please enter city and state manually.",
);
} else {
handleApiFailure(
"Location lookup failed. Please enter city and state manually.",
);
}
} finally {
isLoadingZipcode.value = false;
}
}
}
} }
// Handle API failure by enabling manual entry // Handle API failure by enabling manual entry
function handleApiFailure(formData, message) { function handleApiFailure(message) {
console.warn('Zipcode API failed:', message) console.warn("Zipcode API failed:", message);
// Clear existing data // Clear existing data
availableCities.value = [] availableCities.value = [];
formData.city = '' formData.city = "";
formData.state = '' formData.state = "";
// Show user-friendly message // Show user-friendly message
showStatusMessage(message, 'warning') showStatusMessage(message, "warning");
} }
// Show status message to user // Show status message to user
function showStatusMessage(message, type = 'info') { function showStatusMessage(message, type = "info") {
statusMessage.value = message statusMessage.value = message;
statusType.value = type statusType.value = type;
// Auto-clear message after 5 seconds // Auto-clear message after 5 seconds
setTimeout(() => { setTimeout(() => {
statusMessage.value = '' statusMessage.value = "";
}, 5000) }, 5000);
} }
// Handle form field changes // Get icon class for status messages
function handleFieldChange(event) { function getStatusIcon(type) {
console.log('Field changed:', event) switch (type) {
} case "warning":
return "pi pi-exclamation-triangle";
// Handle form field blur case "error":
function handleFieldBlur(event) { return "pi pi-times-circle";
console.log('Field blurred:', event) case "success":
return "pi pi-check-circle";
default:
return "pi pi-info-circle";
}
} }
// Handle form submission // Handle form submission
function handleSubmit(data) { async function handleSubmit(submittedFormData) {
console.log('Form submitted with data:', data) try {
showStatusMessage("Creating client...", "info");
// TODO: Add API call to create client when ready
// For now, just log the data and close the modal // Convert form data to the expected format
const clientData = {
// Show success message (you can customize this) name: submittedFormData.name,
alert('Client would be created with the following data:\n' + JSON.stringify(data, null, 2)) address: submittedFormData.address,
phone: submittedFormData.phone,
// Close the modal email: submittedFormData.email,
handleClose() zipcode: submittedFormData.zipcode,
city: submittedFormData.city,
state: submittedFormData.state,
};
// Call API to create client
const response = await Api.createClient(clientData);
if (response && response.success) {
showStatusMessage("Client created successfully!", "success");
// Close modal after a brief delay
setTimeout(() => {
handleClose();
}, 1500);
} else {
throw new Error(response?.message || "Failed to create client");
}
} catch (error) {
console.error("Error creating client:", error);
showStatusMessage(error.message || "Failed to create client. Please try again.", "error");
}
} }
// Handle cancel action // Handle cancel action
function handleCancel() { function handleCancel() {
handleClose() handleClose();
} }
// Handle modal close // Handle modal close
function handleClose() { function handleClose() {
modalStore.closeCreateClient() modalStore.closeCreateClient();
resetForm() resetForm();
} }
// Handle visibility changes // Handle visibility changes
function handleVisibilityChange(visible) { function handleVisibilityChange(visible) {
if (!visible) { if (!visible) {
handleClose() handleClose();
} }
} }
// Reset form data // Reset form data
function resetForm() { function resetForm() {
Object.keys(formData).forEach(key => { Object.keys(formData).forEach((key) => {
formData[key] = '' formData[key] = "";
}) });
availableCities.value = [] availableCities.value = [];
isLoadingZipcode.value = false isLoadingZipcode.value = false;
statusMessage.value = '' statusMessage.value = "";
statusType.value = 'info' statusType.value = "info";
} }
// Initialize modal in store when component mounts // Initialize modal in store when component mounts
modalStore.initializeModal('createClient', { modalStore.initializeModal("createClient", {
closeOnEscape: true, closeOnEscape: true,
closeOnOutsideClick: true closeOnOutsideClick: true,
}) });
watch(isVisible, async () => {
if (isVisible.value) {
try {
const names = await Api.getCustomerNames();
console.log("Loaded customer names:", names);
console.log("Customer names type:", typeof names, Array.isArray(names));
console.log("First customer name:", names[0], typeof names[0]);
customerNames.value = names;
// Debug: Let's also set some test data to see if autocomplete works at all
console.log("Setting customerNames to:", customerNames.value);
} catch (error) {
console.error("Error loading customer names:", error);
// Set some test data to debug if autocomplete works
customerNames.value = ["Test Customer 1", "Test Customer 2", "Another Client"];
console.log("Using test customer names:", customerNames.value);
}
}
});
</script> </script>
<style scoped> <style scoped>
.create-client-modal { .create-client-modal {
border-radius: 12px; border-radius: 12px;
} }
/* Custom styling for the modal content */ /* Custom styling for the modal content */
:deep(.modal-header) { :deep(.modal-header) {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white; color: white;
} }
:deep(.modal-title) { :deep(.modal-title) {
font-weight: 600; font-weight: 600;
font-size: 1.25rem; font-size: 1.25rem;
} }
:deep(.modal-close-btn) { :deep(.modal-close-btn) {
color: white !important; color: white !important;
} }
:deep(.modal-content) { :deep(.modal-content) {
padding: 24px; padding: 24px;
} }
/* Form styling adjustments */ /* Form styling adjustments for PrimeVue components */
:deep(.v-text-field) { :deep(.p-inputtext),
margin-bottom: 8px; :deep(.p-dropdown),
:deep(.p-autocomplete) {
margin-bottom: 8px;
} }
:deep(.v-select) { /* Ensure AutoComplete panel appears above modal */
margin-bottom: 8px; :global(.p-autocomplete-overlay) {
z-index: 9999 !important;
} }
:deep(.v-btn) { :global(.p-autocomplete-panel) {
text-transform: none; z-index: 9999 !important;
font-weight: 500;
} }
:deep(.v-btn.v-btn--variant-elevated) { :deep(.p-button) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); text-transform: none;
font-weight: 500;
}
:deep(.p-button:not(.p-button-text)) {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
/* Status message styling */ /* Status message styling */
.status-message { .status-message {
padding: 12px 16px; padding: 12px 16px;
margin-bottom: 16px; margin-bottom: 16px;
border-radius: 6px; border-radius: 6px;
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 0.9rem; font-size: 0.9rem;
border-left: 4px solid; border-left: 4px solid;
}
.status-icon {
margin-right: 8px;
font-size: 1rem;
} }
.status-info { .status-info {
background-color: #e3f2fd; background-color: #e3f2fd;
color: #1565c0; color: #1565c0;
border-left-color: #2196f3; border-left-color: #2196f3;
} }
.status-warning { .status-warning {
background-color: #fff3e0; background-color: #fff3e0;
color: #ef6c00; color: #ef6c00;
border-left-color: #ff9800; border-left-color: #ff9800;
} }
.status-error { .status-error {
background-color: #ffebee; background-color: #ffebee;
color: #c62828; color: #c62828;
border-left-color: #f44336; border-left-color: #f44336;
} }
.status-success { .status-success {
background-color: #e8f5e8; background-color: #e8f5e8;
color: #2e7d32; color: #2e7d32;
border-left-color: #4caf50; border-left-color: #4caf50;
} }
</style> </style>

View File

@ -1639,6 +1639,14 @@ class DataUtils {
materials: ["Sprinkler heads - 3", "Nozzles - 5", "Wire nuts - 10"], materials: ["Sprinkler heads - 3", "Nozzles - 5", "Wire nuts - 10"],
}, },
]; ];
static US_STATES = [
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY'
];
} }
export default DataUtils; export default DataUtils;