bed meeting notes page
This commit is contained in:
parent
ba3e2a4d8e
commit
28c57c4ad0
6 changed files with 17236 additions and 20 deletions
|
|
@ -120,6 +120,7 @@ def submit_bid_meeting_note_form(bid_meeting, project_template, fields, form_tem
|
|||
})
|
||||
new_bid_meeting_note_doc.insert(ignore_permissions=True)
|
||||
for field_row, field in zip(new_bid_meeting_note_doc.fields, fields):
|
||||
print(f"DEBUG: {field_row.label} - {field.get("label")}")
|
||||
if not isinstance(field.get("value"), list):
|
||||
continue
|
||||
for item in field["value"]:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,7 +20,10 @@ def after_install():
|
|||
update_onsite_meeting_fields()
|
||||
update_address_fields()
|
||||
check_and_create_holiday_list()
|
||||
create_snw_install_task_types()
|
||||
create_project_templates()
|
||||
create_task_types()
|
||||
create_tasks()
|
||||
create_bid_meeting_note_form_templates()
|
||||
build_frontend()
|
||||
|
||||
def after_migrate():
|
||||
|
|
@ -35,7 +38,10 @@ def after_migrate():
|
|||
frappe.reload_doctype(doctype)
|
||||
|
||||
check_and_create_holiday_list()
|
||||
create_snw_install_task_types()
|
||||
# create_project_templates()
|
||||
create_task_types()
|
||||
# create_tasks
|
||||
# create_bid_meeting_note_form_templates()
|
||||
|
||||
# update_address_fields()
|
||||
# build_frontend()
|
||||
|
|
@ -1024,10 +1030,11 @@ def get_all_sundays(year):
|
|||
|
||||
return sundays
|
||||
|
||||
def create_snw_install_task_types():
|
||||
def create_task_types():
|
||||
task_types = [
|
||||
{
|
||||
"title": "Locate",
|
||||
"title": "811/Locate",
|
||||
"name": "811/Locate",
|
||||
"description": "Utility locate request prior to installation start.",
|
||||
"base_date": "Start",
|
||||
"offset_days": 7,
|
||||
|
|
@ -1037,6 +1044,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "Permit",
|
||||
"name": "Permit",
|
||||
"description": "Permits required prior to installation start.",
|
||||
"base_date": "Start",
|
||||
"offset_days": 7,
|
||||
|
|
@ -1046,6 +1054,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "1/2 Down Payment",
|
||||
"name": "1/2 Down Payment",
|
||||
"description": "Collect half down payment on project creation.",
|
||||
"calculate_from": "Project",
|
||||
"trigger": "Created",
|
||||
|
|
@ -1053,6 +1062,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "Machine Staging",
|
||||
"name": "Machine Staging",
|
||||
"description": "Stage machinery one day before installation start.",
|
||||
"base_date": "Start",
|
||||
"offset_days": 1,
|
||||
|
|
@ -1062,6 +1072,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "Final Invoice",
|
||||
"name": "Final Invoice",
|
||||
"description": "Send final invoice within 5 days of job completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 5,
|
||||
|
|
@ -1071,6 +1082,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "Backflow Test",
|
||||
"name": "Backflow Test",
|
||||
"description": "Backflow test after job completion if quoted.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 0,
|
||||
|
|
@ -1080,6 +1092,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "Schedule Permit Inspection",
|
||||
"name": "Schedule Permit Inspection",
|
||||
"description": "Schedule permit inspection 5 days after completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 5,
|
||||
|
|
@ -1089,6 +1102,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "15 Day Warranty Follow-Up",
|
||||
"name": "15 Day Warranty Follow-Up",
|
||||
"description": "15-day warranty follow-up after completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 15,
|
||||
|
|
@ -1098,6 +1112,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "30 Day Warranty Check",
|
||||
"name": "30 Day Warranty Check",
|
||||
"description": "30-day warranty check after completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 30,
|
||||
|
|
@ -1107,6 +1122,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "30 Day Payment Reminder",
|
||||
"name": "30 Day Payment Reminder",
|
||||
"description": "Payment reminder sent 30 days after completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 30,
|
||||
|
|
@ -1116,6 +1132,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "60 Day Late Payment Notice",
|
||||
"name": "60 Day Late Payment Notice",
|
||||
"description": "Late payment notification at 60 days post completion.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 60,
|
||||
|
|
@ -1125,6 +1142,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "80 Day Lien Notice",
|
||||
"name": "80 Day Lien Notice",
|
||||
"description": "Lien notice if payment is still late after 80 days.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 80,
|
||||
|
|
@ -1134,6 +1152,7 @@ def create_snw_install_task_types():
|
|||
},
|
||||
{
|
||||
"title": "365 Day Warranty Call / Walk",
|
||||
"name": "365 Day Warranty Call / Walk",
|
||||
"description": "One-year warranty call or walk-through.",
|
||||
"base_date": "Completion",
|
||||
"offset_days": 365,
|
||||
|
|
@ -1145,12 +1164,13 @@ def create_snw_install_task_types():
|
|||
|
||||
for task_type in task_types:
|
||||
# Idempotency check
|
||||
if frappe.db.exists("Task Type", task_type["title"]):
|
||||
if frappe.db.exists("Task Type", task_type["name"]):
|
||||
continue
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Task Type",
|
||||
"title": task_type["title"],
|
||||
"name": task_type["name"],
|
||||
"description": task_type["description"],
|
||||
"base_date": task_type.get("base_date"),
|
||||
"offset_days": task_type.get("offset_days", 0),
|
||||
|
|
@ -1161,5 +1181,141 @@ def create_snw_install_task_types():
|
|||
})
|
||||
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
|
||||
frappe.db.commit()
|
||||
frappe.db.commit()
|
||||
|
||||
def create_tasks():
|
||||
print("\n🔧 Creating default Tasks if they do not exist...")
|
||||
default_tasks = [
|
||||
{
|
||||
"task_name": "Initial Consultation",
|
||||
"description": "Conduct an initial consultation with the client to discuss project requirements.",
|
||||
"status": "Open",
|
||||
"priority": "High",
|
||||
"type": "Consultation"
|
||||
},
|
||||
{
|
||||
"task_name": "Site Survey",
|
||||
"description": "Perform a site survey to assess conditions and gather necessary data.",
|
||||
"status": "Open",
|
||||
"priority": "Medium"
|
||||
},
|
||||
{
|
||||
"task_name": "Design Proposal",
|
||||
"description": "Prepare and present a design proposal based on client needs and site survey findings.",
|
||||
"status": "Open",
|
||||
"priority": "High"
|
||||
}
|
||||
]
|
||||
|
||||
for task in default_tasks:
|
||||
if frappe.db.exists("Task", task["task_name"]):
|
||||
continue
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Task",
|
||||
"is_template": 1,
|
||||
"task_name": task["task_name"],
|
||||
"description": task["description"],
|
||||
"status": task["status"],
|
||||
"priority": task["priority"],
|
||||
"type": task["type"]
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
||||
def create_project_templates():
|
||||
"""Create default Project Templates if they do not exist."""
|
||||
print("\n🔧 Checking for default Project Templates...")
|
||||
templates = {
|
||||
"snw_templates": [
|
||||
{
|
||||
"name": "SNW Install",
|
||||
"project_type": "Service",
|
||||
"company": "Sprinklers Northwest",
|
||||
"calendar_color": "#FF5733" # Example color
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def create_bid_meeting_note_form_templates():
|
||||
"""Create Bid Meeting Note Forms if they do not exist."""
|
||||
print("\n🔧 Checking for Bid Meeting Note Forms...")
|
||||
forms = {
|
||||
"Sprinklers Northwest": [
|
||||
{
|
||||
"name": "SNW Install Bid Meeting Notes",
|
||||
"title": "SNW Install Bid Meeting Notes",
|
||||
"description": "Notes form for SNW Install bid meetings.",
|
||||
"project_template": "SNW Install",
|
||||
"fields": [{
|
||||
"label": "Locate Needed",
|
||||
"type": "Check",
|
||||
"required": 1,
|
||||
"help_text": "Indicate if a locate is needed for this project.",
|
||||
"row": 1,
|
||||
"column": 1
|
||||
},
|
||||
{
|
||||
"label": "Permit Needed",
|
||||
"type": "Check",
|
||||
"required": 1,
|
||||
"help_text": "Indicate if a permit is needed for this project.",
|
||||
"row": 1,
|
||||
"column": 2
|
||||
},
|
||||
{
|
||||
"label": "Back Flow Test Required",
|
||||
"type": "Check",
|
||||
"required": 1,
|
||||
"help_text": "Indicate if a backflow test is required after installation.",
|
||||
"row": 1,
|
||||
"column": 3
|
||||
},
|
||||
{
|
||||
"label": "Machine Access",
|
||||
"type": "Check",
|
||||
"required": 1,
|
||||
"row": 2,
|
||||
"column": 1
|
||||
},
|
||||
{
|
||||
"label": "Machines",
|
||||
"type": "Multi-Select",
|
||||
"options": "MT, Skip Steer, Excavator-E-50, Link Belt, Tre?, Forks, Auger, Backhoe, Loader, Duzer",
|
||||
"required": 0,
|
||||
"include_options": 1,
|
||||
"conditional_on_field": "Machine Access",
|
||||
"row": 2,
|
||||
"column": 2
|
||||
},
|
||||
{
|
||||
"label": "Materials Required",
|
||||
"type": "Check",
|
||||
"required": 1,
|
||||
"row": 3,
|
||||
"column": 0
|
||||
},
|
||||
{
|
||||
"label": "Materials",
|
||||
"type": "Multi-Select w/ Quantity",
|
||||
"required": 0,
|
||||
"doctype_for_select": "Item",
|
||||
"conditional_on_field": "Materials Required",
|
||||
"doctype_label_field": "itemName",
|
||||
"row": 4,
|
||||
"column": 0
|
||||
}]
|
||||
}]}
|
||||
for company, form_list in forms.items():
|
||||
for form in form_list:
|
||||
if frappe.db.exists("Bid Meeting Note Form", form["name"]):
|
||||
continue
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Bid Meeting Note Form",
|
||||
"company": company,
|
||||
"title": form["title"],
|
||||
"description": form["description"],
|
||||
"project_template": form["project_template"],
|
||||
"fields": form["fields"]
|
||||
})
|
||||
doc.insert(ignore_permissions=True)
|
||||
|
|
|
|||
|
|
@ -219,6 +219,12 @@ class Api {
|
|||
});
|
||||
}
|
||||
|
||||
static async getBidMeetingNote(name) {
|
||||
return await this.request("custom_ui.api.db.bid_meetings.get_bid_meeting_note", {
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ESTIMATE / QUOTATION METHODS
|
||||
// ============================================================================
|
||||
|
|
|
|||
532
frontend/src/components/modals/BidMeetingNotes.vue
Normal file
532
frontend/src/components/modals/BidMeetingNotes.vue
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
<template>
|
||||
<div class="bid-meeting-notes">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<p>Loading bid notes...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="bidNote" class="notes-content">
|
||||
<!-- Header Information -->
|
||||
<div class="notes-header">
|
||||
<div class="header-info">
|
||||
<div class="info-item">
|
||||
<span class="label">Meeting:</span>
|
||||
<span class="value">{{ bidNote.bidMeeting }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="bidNote.formTemplate">
|
||||
<span class="label">Template:</span>
|
||||
<span class="value">{{ bidNote.formTemplate }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Created By:</span>
|
||||
<span class="value">{{ bidNote.owner }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Last Modified:</span>
|
||||
<span class="value">{{ formatDate(bidNote.modified) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Notes (if exists) -->
|
||||
<div v-if="bidNote.notes" class="general-notes">
|
||||
<div class="section-header">
|
||||
<i class="pi pi-file-edit"></i>
|
||||
<span>General Notes</span>
|
||||
</div>
|
||||
<div class="notes-text">{{ bidNote.notes }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Fields organized by rows -->
|
||||
<div v-if="fieldsByRow && Object.keys(fieldsByRow).length > 0" class="fields-section">
|
||||
<div v-for="(rowFields, rowIndex) in fieldsByRow" :key="rowIndex" class="field-row">
|
||||
<div v-for="field in rowFields" :key="field.name" class="field-item" :class="`field-type-${field.type.toLowerCase().replace(/\s+/g, '-')}`">
|
||||
<!-- Check if field should be displayed based on conditionals -->
|
||||
<template v-if="shouldShowField(field)">
|
||||
<!-- Check Type -->
|
||||
<div v-if="field.type === 'Check'" class="field-check">
|
||||
<i :class="field.value === '1' ? 'pi pi-check-square' : 'pi pi-square'" :style="{ color: field.value === '1' ? 'var(--primary-color)' : '#999' }"></i>
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Text Type -->
|
||||
<div v-else-if="field.type === 'Text'" class="field-text">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="field-value">{{ field.value || 'N/A' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Select Type -->
|
||||
<div v-else-if="field.type === 'Multi-Select'" class="field-multiselect">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="field-value">
|
||||
<div v-if="getParsedMultiSelect(field.value).length > 0" class="selected-items">
|
||||
<span v-for="(item, idx) in getParsedMultiSelect(field.value)" :key="idx" class="selected-item">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="no-selection">No items selected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multi-Select w/ Quantity Type -->
|
||||
<div v-else-if="field.type === 'Multi-Select w/ Quantity'" class="field-multiselect-qty">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="field-value">
|
||||
<div v-if="loading" class="loading-items">
|
||||
<v-progress-circular size="20" indeterminate></v-progress-circular>
|
||||
<span>Loading items...</span>
|
||||
</div>
|
||||
<div v-else-if="getParsedMultiSelectQty(field.value).length > 0" class="quantity-items">
|
||||
<div v-for="(item, idx) in getParsedMultiSelectQty(field.value)" :key="idx" class="quantity-item">
|
||||
<span class="item-label">{{ getItemLabel(field, item) }}</span>
|
||||
<span class="item-quantity">Qty: {{ item.quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-selection">No items selected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Default/Unknown Type -->
|
||||
<div v-else class="field-unknown">
|
||||
<div class="field-label">{{ field.label }}</div>
|
||||
<div class="field-value">{{ field.value || 'N/A' }}</div>
|
||||
<div class="field-type-note">(Type: {{ field.type }})</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="empty-state">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>No fields to display</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
const notificationStore = useNotificationStore();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
bidNote: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Local state
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
const doctypeCache = ref({});
|
||||
|
||||
// Organize fields by row
|
||||
const fieldsByRow = computed(() => {
|
||||
if (!props.bidNote?.fields) return {};
|
||||
|
||||
const rows = {};
|
||||
props.bidNote.fields.forEach(field => {
|
||||
const rowNum = field.row || 0;
|
||||
if (!rows[rowNum]) {
|
||||
rows[rowNum] = [];
|
||||
}
|
||||
rows[rowNum].push(field);
|
||||
});
|
||||
|
||||
// Sort fields within each row by column
|
||||
Object.keys(rows).forEach(rowNum => {
|
||||
rows[rowNum].sort((a, b) => (a.column || 0) - (b.column || 0));
|
||||
});
|
||||
|
||||
return rows;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const shouldShowField = (field) => {
|
||||
// If no conditional, always show
|
||||
if (!field.conditionalOnField) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the field this one depends on
|
||||
const parentField = props.bidNote.fields.find(f => f.label === field.conditionalOnField);
|
||||
if (!parentField) {
|
||||
return true; // If parent not found, show anyway
|
||||
}
|
||||
|
||||
// For checkboxes, show if checked
|
||||
if (parentField.type === 'Check') {
|
||||
return parentField.value === '1';
|
||||
}
|
||||
|
||||
// If conditional value is specified, check against it
|
||||
if (field.conditionalOnValue) {
|
||||
return parentField.value === field.conditionalOnValue;
|
||||
}
|
||||
|
||||
// Otherwise, show if parent has any value
|
||||
return !!parentField.value;
|
||||
};
|
||||
|
||||
const getParsedMultiSelect = (value) => {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
console.error("Error parsing multi-select value:", e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getParsedMultiSelectQty = (value) => {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
console.error("Error parsing multi-select with quantity value:", e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getItemLabel = (field, item) => {
|
||||
// If we have a cached label from doctype lookup
|
||||
if (item.fetchedLabel) {
|
||||
return item.fetchedLabel;
|
||||
}
|
||||
|
||||
// If label is provided in the item itself
|
||||
if (item.label) {
|
||||
return item.label;
|
||||
}
|
||||
|
||||
// Otherwise use the item ID
|
||||
return item.item || 'Unknown Item';
|
||||
};
|
||||
|
||||
const fetchDoctypeData = async (field, itemId) => {
|
||||
if (!field.valueDoctype || !itemId) return null;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${field.valueDoctype}:${itemId}`;
|
||||
if (doctypeCache.value[cacheKey]) {
|
||||
return doctypeCache.value[cacheKey];
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the getDetailedDoc method from the API
|
||||
const data = await Api.getDetailedDoc(field.valueDoctype, itemId);
|
||||
|
||||
// Cache the result
|
||||
doctypeCache.value[cacheKey] = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching doctype ${field.valueDoctype}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadDoctypeLabels = async () => {
|
||||
if (!props.bidNote?.fields) return;
|
||||
|
||||
// Find all Multi-Select w/ Quantity fields that have valueDoctype
|
||||
const quantityFields = props.bidNote.fields.filter(
|
||||
f => f.type === 'Multi-Select w/ Quantity' && f.valueDoctype
|
||||
);
|
||||
|
||||
for (const field of quantityFields) {
|
||||
const items = getParsedMultiSelectQty(field.value);
|
||||
for (const item of items) {
|
||||
if (item.item && !item.fetchedLabel) {
|
||||
const data = await fetchDoctypeData(field, item.item);
|
||||
if (data && field.doctypeLabelField) {
|
||||
// Add the fetched label to the item
|
||||
item.fetchedLabel = data[field.doctypeLabelField] || item.item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
await loadDoctypeLabels();
|
||||
} catch (err) {
|
||||
console.error("Error loading bid note details:", err);
|
||||
error.value = "Failed to load some field details";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bid-meeting-notes {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.error-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.notes-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.notes-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.9;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-weight: 600;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.general-notes {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
margin-bottom: 12px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.section-header i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.notes-text {
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fields-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field-item {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.field-item:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-check i {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.field-check .field-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.field-text,
|
||||
.field-multiselect,
|
||||
.field-multiselect-qty,
|
||||
.field-unknown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 0.9em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.selected-items {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.selected-item {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quantity-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quantity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.item-quantity {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.no-selection {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.field-type-note {
|
||||
font-size: 0.75em;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.header-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
<template>
|
||||
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
Meeting Details
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<Modal :visible="showModal" @update:visible="showModal = $event" :options="modalOptions" @confirm="handleClose">
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-calendar" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
Meeting Details
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="meeting" class="meeting-details">
|
||||
<!-- Status Badge -->
|
||||
<div class="status-section">
|
||||
|
|
@ -139,6 +140,17 @@
|
|||
Create Notes and Complete
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="meeting.status === 'Completed' && meeting.bidNotes"
|
||||
@click="handleViewBidNotes"
|
||||
color="info"
|
||||
variant="elevated"
|
||||
:loading="loadingBidNotes"
|
||||
>
|
||||
<v-icon left>mdi-note-text</v-icon>
|
||||
View Bid Notes
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="meeting.status === 'Completed'"
|
||||
@click="handleCreateEstimate"
|
||||
|
|
@ -151,12 +163,38 @@
|
|||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Bid Notes Modal -->
|
||||
<Modal
|
||||
:visible="showBidNotesModal"
|
||||
@update:visible="showBidNotesModal = $event"
|
||||
:options="bidNotesModalOptions"
|
||||
@confirm="handleCloseBidNotes"
|
||||
>
|
||||
<template #title>
|
||||
<div class="modal-header">
|
||||
<i class="pi pi-file-edit" style="color: var(--primary-color); font-size: 1.2rem; margin-right: 0.5rem;"></i>
|
||||
Bid Meeting Notes
|
||||
</div>
|
||||
</template>
|
||||
<BidMeetingNotes v-if="bidNoteData" :bid-note="bidNoteData" />
|
||||
<div v-else-if="bidNotesError" class="error-message">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>{{ bidNotesError }}</span>
|
||||
</div>
|
||||
<div v-else class="loading-message">
|
||||
<v-progress-circular indeterminate color="primary"></v-progress-circular>
|
||||
<span>Loading bid notes...</span>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Modal from "../common/Modal.vue";
|
||||
import BidMeetingNotes from "./BidMeetingNotes.vue";
|
||||
import Api from "../../api";
|
||||
import { useNotificationStore } from "../../stores/notifications-primevue";
|
||||
|
||||
|
|
@ -180,6 +218,10 @@ const emit = defineEmits(["update:visible", "close", "meetingUpdated", "complete
|
|||
|
||||
// Local state
|
||||
const isUpdating = ref(false);
|
||||
const showBidNotesModal = ref(false);
|
||||
const bidNoteData = ref(null);
|
||||
const loadingBidNotes = ref(false);
|
||||
const bidNotesError = ref(null);
|
||||
|
||||
const showModal = computed({
|
||||
get() {
|
||||
|
|
@ -198,6 +240,13 @@ const modalOptions = computed(() => ({
|
|||
confirmButtonColor: "primary",
|
||||
}));
|
||||
|
||||
const bidNotesModalOptions = computed(() => ({
|
||||
maxWidth: "1000px",
|
||||
showCancelButton: false,
|
||||
confirmButtonText: "Close",
|
||||
confirmButtonColor: "primary",
|
||||
}));
|
||||
|
||||
// Computed properties for data extraction
|
||||
const customerName = computed(() => {
|
||||
if (props.meeting?.address?.customerName) {
|
||||
|
|
@ -322,6 +371,43 @@ const formatDateTime = (dateString) => {
|
|||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const handleViewBidNotes = async () => {
|
||||
if (!props.meeting?.bidNotes) return;
|
||||
|
||||
try {
|
||||
loadingBidNotes.value = true;
|
||||
bidNotesError.value = null;
|
||||
bidNoteData.value = null;
|
||||
|
||||
// Fetch the bid meeting note
|
||||
const noteData = await Api.getBidMeetingNote(props.meeting.bidNotes);
|
||||
|
||||
if (!noteData) {
|
||||
throw new Error("Failed to load bid notes");
|
||||
}
|
||||
|
||||
bidNoteData.value = noteData;
|
||||
showBidNotesModal.value = true;
|
||||
} catch (error) {
|
||||
console.error("Error loading bid notes:", error);
|
||||
bidNotesError.value = error.message || "Failed to load bid notes";
|
||||
notificationStore.addNotification({
|
||||
type: "error",
|
||||
title: "Error",
|
||||
message: "Failed to load bid notes. Please try again.",
|
||||
duration: 5000,
|
||||
});
|
||||
} finally {
|
||||
loadingBidNotes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseBidNotes = () => {
|
||||
showBidNotesModal.value = false;
|
||||
bidNoteData.value = null;
|
||||
bidNotesError.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -363,5 +449,24 @@ const formatDateTime = (dateString) => {
|
|||
padding-top: 16px;
|
||||
border-top: 2px solid #e0e0e0;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.loading-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.error-message i {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue