multiple bid meetings for one cell, final invoice flow.

This commit is contained in:
Casey 2026-02-13 11:22:32 -06:00
parent b3e6e4f6a2
commit caa4bc2dca
21 changed files with 1132 additions and 1551 deletions

View file

@ -46,6 +46,7 @@ const FRAPPE_GET_INVOICES_METHOD = "custom_ui.api.db.invoices.get_invoice_table_
const FRAPPE_UPSERT_INVOICE_METHOD = "custom_ui.api.db.invoices.upsert_invoice";
const FRAPPE_GET_INVOICES_LATE_METHOD = "custom_ui.api.db.invoices.get_invoices_late_count";
const FRAPPE_CREATE_INVOICE_FOR_JOB = "custom_ui.api.db.invoices.create_invoice_for_job";
const FRAPPE_SUBMIT_AND_SEND_INVOICE_METHOD = "custom_ui.api.db.invoices.submit_and_send_invoice";
// Warranty methods
const FRAPPE_GET_WARRANTY_CLAIMS_METHOD = "custom_ui.api.db.warranties.get_warranty_claims";
// On-Site Meeting methods
@ -574,8 +575,12 @@ class Api {
}
static async sendInvoice(invoiceName) {
return await this.request(FRAPPE_SUBMIT_AND_SEND_INVOICE_METHOD, { invoiceName });
}
static async createInvoice(invoiceData) {
const payload = DataUtils.toSnakeCaseObject(invoiceData);
// const payload = DataUtils.toSnakeCaseObject(invoiceData);
const result = await this.request(FRAPPE_UPSERT_INVOICE_METHOD, { data: payload });
console.log("DEBUG: API - Created Invoice: ", result);
return result;

View file

@ -101,11 +101,13 @@
dragOverSlot?.date === day.date &&
dragOverSlot?.time === timeSlot.time,
weekend: day.isWeekend,
'has-multiple-meetings': getMeetingsForTimeSlot(day.date, timeSlot.time).length > 1,
}"
@click="selectTimeSlot(day.date, timeSlot.time)"
@dragover="handleDragOver($event, day.date, timeSlot.time)"
@dragleave="handleDragLeave"
@drop="handleDrop($event, day.date, timeSlot.time)"
:ref="el => dayCells[`${day.date}-${timeSlot.time}`] = el"
>
<!-- Meetings in this time slot -->
<div
@ -119,7 +121,7 @@
:draggable="meeting.status !== 'Completed'"
@dragstart="handleMeetingDragStart($event, meeting)"
@dragend="handleDragEnd($event)"
@click.stop="showMeetingDetails(meeting)"
@drop="handleDrop($event, day.date, timeSlot.time)"
>
<div class="event-time">
{{ formatTimeDisplay(meeting.scheduledTime) }}
@ -135,6 +137,24 @@
</div>
</div>
<!-- Slot Popup for Multiple Meetings -->
<div v-if="showSlotPopup" class="slot-popup-overlay" @click="showSlotPopup = false">
<div class="slot-popup" :style="popupStyle">
<div class="popup-header">
{{ formatTimeDisplay(popupPosition.time) }} - {{ formatDate(popupPosition.date) }}
</div>
<div
v-for="meeting in popupMeetings"
:key="meeting.id"
class="popup-meeting-item"
@click.stop="showMeetingDetails(meeting); showSlotPopup = false"
>
<div class="meeting-address">{{ meeting.address?.fullAddress || meeting.address }}</div>
<div class="meeting-client">{{ meeting.client }}</div>
</div>
</div>
</div>
<!-- Right Sidebar for Unscheduled Meetings -->
<div class="sidebar sidebar-right" :class="{ collapsed: isSidebarCollapsed }">
<div class="sidebar-header">
@ -274,6 +294,13 @@ const draggedMeeting = ref(null);
const originalMeetingForReschedule = ref(null); // Store original meeting data for reschedules
const isUnscheduledDragOver = ref(false);
// Slot popup state
const showSlotPopup = ref(false);
const popupMeetings = ref([]);
const popupPosition = ref({});
const popupStyle = ref({});
const dayCells = ref({});
// Sidebar state
const isSidebarCollapsed = ref(false);
@ -313,7 +340,7 @@ const weekDays = computed(() => {
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
days.push({
date: day.toISOString().split("T")[0],
date: `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`,
dayName: day.toLocaleDateString("en-US", { weekday: "short" }),
displayDate: day.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
isToday,
@ -472,7 +499,25 @@ const getAddressText = (address) => {
};
const selectTimeSlot = (date, time) => {
console.log("Selected time slot:", date, time);
const meetingsInSlot = getMeetingsForTimeSlot(date, time);
if (meetingsInSlot.length === 1) {
showMeetingDetails(meetingsInSlot[0]);
} else if (meetingsInSlot.length > 1) {
// Show popup with list of meetings
const cellKey = `${date}-${time}`;
const cell = dayCells.value[cellKey];
if (cell) {
const rect = cell.getBoundingClientRect();
popupStyle.value = {
top: `${rect.bottom + window.scrollY + 5}px`,
left: `${rect.left + window.scrollX}px`,
};
}
popupMeetings.value = meetingsInSlot;
popupPosition.value = { date, time };
showSlotPopup.value = true;
}
// If no meetings, do nothing for now
};
const showMeetingDetails = (meeting) => {
@ -964,15 +1009,8 @@ const loadWeekMeetings = async () => {
meetings.value = apiResult
.map((meeting) => {
// Extract date and time from startTime
const startDateTime = meeting.startTime
? new Date(meeting.startTime)
: null;
const date = startDateTime
? startDateTime.toISOString().split("T")[0]
: null;
const time = startDateTime
? `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`
: null;
const date = meeting.startTime ? meeting.startTime.split(' ')[0] : null;
const time = meeting.startTime ? meeting.startTime.split(' ')[1].substring(0,5) : null;
// Return the full meeting object with calendar-specific fields added
return {
@ -1108,8 +1146,8 @@ const findAndDisplayMeetingByName = async () => {
// Parse the start time to get date and time
const startDateTime = new Date(meetingData.startTime);
const meetingDate = startDateTime.toISOString().split("T")[0];
const meetingTime = `${startDateTime.getHours().toString().padStart(2, "0")}:${startDateTime.getMinutes().toString().padStart(2, "0")}`;
const meetingDate = meetingData.startTime.split(' ')[0];
const meetingTime = meetingData.startTime.split(' ')[1].substring(0,5);
// Navigate to the week containing this meeting
currentWeekStart.value = new Date(
@ -1636,24 +1674,60 @@ watch(
font-size: 0.9em;
}
/* Responsive design */
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
.day-column.has-multiple-meetings {
border: 2px solid #ff9800;
background-color: #fff3e0;
}
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
padding-right: 0;
padding-bottom: 20px;
max-height: 200px;
}
.slot-popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 1000;
background: transparent;
}
.calendar-header-row,
.time-row {
grid-template-columns: 60px repeat(7, 1fr);
}
.slot-popup {
position: absolute;
background: white;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
padding: 8px;
min-width: 250px;
max-width: 300px;
z-index: 1001;
}
.popup-header {
font-weight: bold;
margin-bottom: 8px;
font-size: 0.9em;
}
.popup-meeting-item {
padding: 4px 0;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.popup-meeting-item:hover {
background-color: #f5f5f5;
}
.popup-meeting-item:last-child {
border-bottom: none;
}
.meeting-address {
font-size: 0.8em;
font-weight: 500;
}
.meeting-client {
font-size: 0.7em;
color: #666;
}
</style>

View file

@ -26,10 +26,30 @@
<span class="btn-icon">📧</span>
Email Customer
</button>
<button class="btn btn-primary" @click="createInvoiceForJob()">
<button
class="btn btn-primary"
@click="createInvoiceForJob()"
:disabled="!canCreateInvoice"
>
<span class="btn-icon">💰</span>
Create Invoice
</button>
<button
class="btn btn-secondary"
@click="viewInvoice()"
:disabled="!canViewInvoice"
>
<span class="btn-icon">👁</span>
View Invoice
</button>
<button
class="btn btn-secondary"
@click="sendInvoice()"
:disabled="!canSendInvoice"
>
<span class="btn-icon">📤</span>
{{ sendInvoiceButtonText }}
</button>
</div>
</div>
@ -446,6 +466,26 @@ const filteredTasks = computed(() => {
return job.value.tasks;
});
const canCreateInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Ready to Invoice';
});
const canViewInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Created' || status === 'Invoice Sent';
});
const canSendInvoice = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Created' || status === 'Invoice Sent';
});
const sendInvoiceButtonText = computed(() => {
const status = job.value?.invoiceStatus;
return status === 'Invoice Sent' ? 'Resend Invoice' : 'Send Invoice';
});
const getProgressOffset = (percent) => {
const circumference = 2 * Math.PI * 45;
const offset = circumference - (percent / 100) * circumference;
@ -537,6 +577,7 @@ const createInvoiceForJob = async () => {
if (!job.value) return;
try {
await Api.createInvoiceForJob(job.value.name);
job.value.invoiceStatus = "Invoice Created";
notifications.addSuccess("Invoice created successfully");
} catch (error) {
console.error("Error creating invoice:", error);
@ -544,6 +585,19 @@ const createInvoiceForJob = async () => {
}
};
const viewInvoice = () => {
// Placeholder method for viewing invoice
console.log("View Invoice clicked");
notifications.addInfo("View Invoice functionality - Coming soon!");
};
const sendInvoice = async () => {
// Placeholder method for sending invoice
await Api.sendInvoice(job.value.invoice.name)
job.value.invoiceStatus = "Invoice Sent";
console.log("Send Invoice clicked");
};
const navigateToCalendar = () => {
router.push('/calendar?tab=projects');
};
@ -771,8 +825,20 @@ onMounted(async () => {
border: 1px solid #ced4da;
}
.btn-secondary:hover {
background: #f8f9fa;
.btn:disabled {
background: #e9ecef;
color: #adb5bd;
cursor: not-allowed;
opacity: 0.6;
transform: none;
box-shadow: none;
}
.btn:disabled:hover {
background: #e9ecef;
color: #adb5bd;
transform: none;
box-shadow: none;
}
.btn-icon {
@ -989,24 +1055,24 @@ onMounted(async () => {
}
.financial-details {
display: flex;
flex-direction: column;
gap: 6px;
display: grid;
grid-template-columns: 1fr auto;
gap: 6px 12px;
margin-top: 12px;
padding-top: 0;
border-top: none;
align-items: center;
}
.financial-item {
display: flex;
justify-content: space-between;
align-items: center;
display: contents;
}
.item-label {
font-size: 12px;
color: #6c757d;
font-weight: 500;
justify-self: start;
}
.item-value {
@ -1014,6 +1080,7 @@ onMounted(async () => {
font-weight: 600;
padding: 2px 8px;
border-radius: 3px;
justify-self: end;
}
.item-value.paid {