bed meeting notes page

This commit is contained in:
Casey 2026-01-25 05:36:57 -06:00
parent ba3e2a4d8e
commit 28c57c4ad0
6 changed files with 17236 additions and 20 deletions

View file

@ -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
// ============================================================================

View 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>

View file

@ -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>