multiple bid meetings for one cell, final invoice flow.
This commit is contained in:
parent
b3e6e4f6a2
commit
caa4bc2dca
21 changed files with 1132 additions and 1551 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue