update estimate page, add stripe script and docker compose for mail server

This commit is contained in:
Casey 2026-02-03 17:04:04 -06:00
parent 678eb18583
commit 9a7e3fe740
17 changed files with 1168 additions and 66 deletions

View file

@ -229,8 +229,8 @@ class Api {
// ESTIMATE / QUOTATION METHODS
// ============================================================================
static async getQuotationItems() {
return await this.request("custom_ui.api.db.estimates.get_quotation_items");
static async getQuotationItems(projectTemplate) {
return await this.request("custom_ui.api.db.estimates.get_quotation_items", { projectTemplate });
}
static async getEstimateFromAddress(fullAddress) {

View file

@ -0,0 +1,145 @@
<template>
<div class="items-container">
<div v-if="items.length === 0" class="no-items-message">
<i class="pi pi-inbox"></i>
<p>{{ emptyMessage }}</p>
</div>
<div v-else v-for="item in items" :key="item.itemCode" class="item-card" :class="{ 'item-selected': item._selected }" @click="handleItemClick(item, $event)">
<div class="item-card-header">
<span class="item-code">{{ item.itemCode }}</span>
<span class="item-name">{{ item.itemName }}</span>
<span class="item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
<Button
:label="item._selected ? 'Selected' : 'Select'"
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
@click.stop="handleItemClick(item, $event)"
size="small"
:severity="item._selected ? 'success' : 'secondary'"
class="select-item-button"
/>
</div>
<div v-if="item.description" class="item-description">
{{ item.description }}
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import Button from "primevue/button";
const props = defineProps({
items: {
type: Array,
required: true,
default: () => []
},
emptyMessage: {
type: String,
default: "No items found in this category"
}
});
const emit = defineEmits(['select']);
const selectedItems = ref([]);
const handleItemClick = (item, event) => {
// Always multi-select mode - toggle item in selection
const index = selectedItems.value.findIndex(i => i.itemCode === item.itemCode);
if (index >= 0) {
selectedItems.value.splice(index, 1);
} else {
selectedItems.value.push(item);
}
// Emit the entire selection array
emit('select', [...selectedItems.value]);
};
</script>
<style scoped>
.items-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
overflow-y: scroll;
padding: 0.5rem;
}
.no-items-message {
text-align: center;
padding: 3rem 2rem;
color: #666;
}
.no-items-message i {
font-size: 3em;
color: #ccc;
margin-bottom: 1rem;
display: block;
}
.no-items-message p {
margin: 0;
font-size: 1rem;
}
.item-card {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.75rem;
background-color: #fafafa;
transition: all 0.2s ease;
cursor: pointer;
}
.item-card:hover {
background-color: #f0f0f0;
border-color: #2196f3;
}
.item-selected {
background-color: #e3f2fd;
border-color: #2196f3;
border-width: 2px;
}
.item-card-header {
display: grid;
grid-template-columns: 150px 1fr 120px 100px;
align-items: center;
gap: 1rem;
}
.item-code {
font-weight: 600;
color: #333;
font-family: monospace;
font-size: 0.9rem;
}
.item-name {
color: #555;
}
.item-price {
font-weight: 600;
color: #2196f3;
text-align: right;
}
.select-item-button {
justify-self: end;
}
.item-description {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #e0e0e0;
color: #666;
font-size: 0.9rem;
line-height: 1.4;
}
</style>

View file

@ -0,0 +1,526 @@
<template>
<Modal
:visible="visible"
@update:visible="$emit('update:visible', $event)"
@close="handleClose"
:options="{ showActions: false, maxWidth: '90vw', width: '1350px' }"
class="add-item-modal"
>
<template #title>
<div class="modal-title-container">
<span>Add Item</span>
<span v-if="selectedItemsCount > 0" class="selection-badge">{{ selectedItemsCount }} selected</span>
</div>
</template>
<div class="modal-content items-modal-content">
<div class="search-section">
<label for="item-search" class="field-label">Search Items</label>
<InputText
id="item-search"
v-model="searchTerm"
placeholder="Search by item code or name..."
fluid
/>
</div>
<div class="tabs-container">
<Tabs v-model="activeItemTab" v-if="itemGroups.length > 0 || packageGroups.length > 0">
<TabList>
<Tab v-for="group in itemGroups" :key="group" :value="group">{{ group }}</Tab>
<Tab v-if="packageGroups.length > 0" value="Packages">Packages</Tab>
</TabList>
<TabPanels>
<!-- Regular category tabs -->
<TabPanel v-for="group in itemGroups" :key="group" :value="group">
<ItemSelector :items="getFilteredItemsForGroup(group)" @select="handleItemSelection" />
</TabPanel>
<!-- Packages tab with nested sub-tabs -->
<TabPanel v-if="packageGroups.length > 0" value="Packages">
<Tabs v-model="activePackageTab" class="nested-tabs">
<TabList>
<Tab v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">{{ packageGroup }}</Tab>
</TabList>
<TabPanels>
<TabPanel v-for="packageGroup in packageGroups" :key="packageGroup" :value="packageGroup">
<div class="package-items-container">
<div v-for="item in getFilteredPackageItemsForGroup(packageGroup)" :key="item.itemCode" class="package-item" :class="{ 'package-item-selected': item._selected }">
<div class="package-item-header">
<Button
:icon="isPackageExpanded(item.itemCode) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"
@click="togglePackageExpansion(item.itemCode)"
text
rounded
class="expand-button"
/>
<span class="package-item-code">{{ item.itemCode }}</span>
<span class="package-item-name">{{ item.itemName }}</span>
<span class="package-item-price">${{ item.standardRate?.toFixed(2) || '0.00' }}</span>
<Button
:label="item._selected ? 'Selected' : 'Select'"
:icon="item._selected ? 'pi pi-check' : 'pi pi-plus'"
@click="handleItemSelection(item)"
size="small"
:severity="item._selected ? 'success' : 'secondary'"
class="add-package-button"
/>
</div>
<div v-if="isPackageExpanded(item.itemCode) && item.bom" class="bom-details">
<div class="bom-header">Bill of Materials:</div>
<div v-for="bomItem in (item.bom.items || [])" :key="bomItem.itemCode" class="bom-item">
<span class="bom-item-code">{{ bomItem.itemCode }}</span>
<span class="bom-item-name">{{ bomItem.itemName }}</span>
<span class="bom-item-qty">Qty: {{ bomItem.qty }}</span>
</div>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
</TabPanel>
</TabPanels>
</Tabs>
<!-- Fallback if no categories -->
<ItemSelector v-else :items="[]" empty-message="No items available. Please select a Project Template first." />
</div>
<div class="modal-actions">
<Button
label="Clear Selection"
@click="clearSelection"
severity="secondary"
:disabled="selectedItemsCount === 0"
/>
<Button
:label="`Add ${selectedItemsCount} Item${selectedItemsCount !== 1 ? 's' : ''}`"
@click="addItems"
icon="pi pi-plus"
:disabled="selectedItemsCount === 0"
/>
</div>
</div>
</Modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import Modal from "../common/Modal.vue";
import ItemSelector from "../common/ItemSelector.vue";
import InputText from "primevue/inputtext";
import Button from "primevue/button";
import Tabs from "primevue/tabs";
import TabList from "primevue/tablist";
import Tab from "primevue/tab";
import TabPanels from "primevue/tabpanels";
import TabPanel from "primevue/tabpanel";
const props = defineProps({
visible: {
type: Boolean,
required: true
},
quotationItems: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['update:visible', 'add-items']);
const searchTerm = ref("");
const expandedPackageItems = ref(new Set());
const selectedItemsInModal = ref(new Set());
const itemGroups = computed(() => {
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
// Get all keys except 'Packages'
const groups = Object.keys(props.quotationItems).filter(key => key !== 'Packages').sort();
return groups;
});
const packageGroups = computed(() => {
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
return Object.keys(props.quotationItems.Packages).sort();
});
// Active tabs with default to first category
const activeItemTab = computed({
get: () => _activeItemTab.value || itemGroups.value[0] || "",
set: (val) => { _activeItemTab.value = val; }
});
const activePackageTab = computed({
get: () => _activePackageTab.value || packageGroups.value[0] || "",
set: (val) => { _activePackageTab.value = val; }
});
const _activeItemTab = ref("");
const _activePackageTab = ref("");
const getFilteredItemsForGroup = (group) => {
if (!props.quotationItems || typeof props.quotationItems !== 'object') return [];
let items = [];
// Get items from the specified group
if (group && props.quotationItems[group]) {
items = [...props.quotationItems[group]];
}
// Filter by search term
if (searchTerm.value.trim()) {
const term = searchTerm.value.toLowerCase();
items = items.filter(
(item) =>
item.itemCode?.toLowerCase().includes(term) ||
item.itemName?.toLowerCase().includes(term),
);
}
return items.map((item) => ({
...item,
id: item.itemCode,
_selected: selectedItemsInModal.value.has(item.itemCode)
}));
};
const getFilteredPackageItemsForGroup = (packageGroup) => {
if (!props.quotationItems?.Packages || typeof props.quotationItems.Packages !== 'object') return [];
let items = [];
// Get items from the specified package group
if (packageGroup && props.quotationItems.Packages[packageGroup]) {
items = [...props.quotationItems.Packages[packageGroup]];
}
// Filter by search term
if (searchTerm.value.trim()) {
const term = searchTerm.value.toLowerCase();
items = items.filter(
(item) =>
item.itemCode?.toLowerCase().includes(term) ||
item.itemName?.toLowerCase().includes(term),
);
}
return items.map((item) => ({
...item,
id: item.itemCode,
_selected: selectedItemsInModal.value.has(item.itemCode)
}));
};
const selectedItemsCount = computed(() => selectedItemsInModal.value.size);
const togglePackageExpansion = (itemCode) => {
if (expandedPackageItems.value.has(itemCode)) {
expandedPackageItems.value.delete(itemCode);
} else {
expandedPackageItems.value.add(itemCode);
}
// Force reactivity
expandedPackageItems.value = new Set(expandedPackageItems.value);
};
const isPackageExpanded = (itemCode) => {
return expandedPackageItems.value.has(itemCode);
};
const handleItemSelection = (itemOrRows) => {
// Handle both single item (from package cards) and array (from DataTable)
if (Array.isArray(itemOrRows)) {
// From DataTable - replace selection with new array
selectedItemsInModal.value = new Set(itemOrRows.map(row => row.itemCode));
} else {
// From package card - toggle single item
if (selectedItemsInModal.value.has(itemOrRows.itemCode)) {
selectedItemsInModal.value.delete(itemOrRows.itemCode);
} else {
selectedItemsInModal.value.add(itemOrRows.itemCode);
}
// Force reactivity
selectedItemsInModal.value = new Set(selectedItemsInModal.value);
}
};
const clearSelection = () => {
selectedItemsInModal.value = new Set();
};
const addItems = () => {
// Get all selected items from all categories
const allItems = [];
// Collect from regular categories
if (props.quotationItems && typeof props.quotationItems === 'object') {
Object.keys(props.quotationItems).forEach(key => {
if (key !== 'Packages' && Array.isArray(props.quotationItems[key])) {
props.quotationItems[key].forEach(item => {
if (selectedItemsInModal.value.has(item.itemCode)) {
allItems.push(item);
}
});
}
});
// Collect from Packages sub-categories
if (props.quotationItems.Packages && typeof props.quotationItems.Packages === 'object') {
Object.keys(props.quotationItems.Packages).forEach(subKey => {
if (Array.isArray(props.quotationItems.Packages[subKey])) {
props.quotationItems.Packages[subKey].forEach(item => {
if (selectedItemsInModal.value.has(item.itemCode)) {
allItems.push(item);
}
});
}
});
}
}
if (allItems.length > 0) {
emit('add-items', allItems);
selectedItemsInModal.value = new Set();
}
};
const handleClose = () => {
selectedItemsInModal.value = new Set();
searchTerm.value = "";
emit('update:visible', false);
};
// Watch modal visibility to reset state when closing
watch(() => props.visible, (newVal) => {
if (newVal) {
// Modal is opening - reset to first tabs
_activeItemTab.value = "";
_activePackageTab.value = "";
} else {
// Modal is closing - reset state
selectedItemsInModal.value = new Set();
searchTerm.value = "";
}
});
</script>
<style scoped>
.items-modal-content {
height: 70vh;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
}
.tabs-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tabs-container :deep(.p-tabs) {
display: flex;
flex-direction: column;
height: 100%;
}
.tabs-container :deep(.p-tabpanels) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.tabs-container :deep(.p-tabpanel) {
height: 100%;
overflow: hidden;
}
.search-section {
/* margin removed - parent gap handles spacing */
}
.field-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.tip-section {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background-color: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 4px;
color: #1565c0;
font-size: 0.9rem;
}
.tip-section i {
color: #2196f3;
}
.tip-section kbd {
background-color: #fff;
border: 1px solid #ccc;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.85em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.modal-title-container {
display: flex;
align-items: center;
gap: 1rem;
}
.selection-badge {
background-color: #2196f3;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e0e0e0;
flex-shrink: 0;
}
.nested-tabs {
display: flex;
flex-direction: column;
height: 100%;
}
.nested-tabs :deep(.p-tabs) {
display: flex;
flex-direction: column;
height: 100%;
}
.nested-tabs :deep(.p-tabpanels) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.nested-tabs :deep(.p-tabpanel) {
height: 100%;
overflow: hidden;
}
.package-items-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: 100%;
overflow-y: scroll;
padding: 0.5rem;
}
.package-item {
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 0.75rem;
background-color: #fafafa;
transition: all 0.2s ease;
}
.package-item:hover {
background-color: #f0f0f0;
border-color: #2196f3;
}
.package-item-selected {
background-color: #e3f2fd;
border-color: #2196f3;
border-width: 2px;
}
.package-item-header {
display: grid;
grid-template-columns: 40px 120px 1fr 100px 80px;
align-items: center;
gap: 1rem;
}
.expand-button {
width: 2rem;
height: 2rem;
}
.package-item-code {
font-weight: 600;
color: #333;
font-family: monospace;
}
.package-item-name {
color: #555;
}
.package-item-price {
font-weight: 600;
color: #2196f3;
text-align: right;
}
.add-package-button {
justify-self: end;
}
.bom-details {
margin-top: 0.75rem;
padding: 0.75rem;
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.bom-header {
font-weight: 600;
margin-bottom: 0.5rem;
color: #666;
font-size: 0.9rem;
}
.bom-item {
display: grid;
grid-template-columns: 120px 1fr 100px;
gap: 1rem;
padding: 0.5rem;
border-bottom: 1px solid #f0f0f0;
align-items: center;
}
.bom-item:last-child {
border-bottom: none;
}
.bom-item-code {
font-family: monospace;
color: #666;
font-size: 0.85rem;
}
.bom-item-name {
color: #555;
font-size: 0.85rem;
}
.bom-item-qty {
color: #888;
font-size: 0.85rem;
text-align: right;
}
</style>

View file

@ -20,24 +20,27 @@
<div v-else-if="formConfig" class="form-container">
<Button @click="debugLog" label="Debug" severity="secondary" size="small" class="debug-button" />
<template v-for="row in groupedFields" :key="`row-${row.rowIndex}`">
<div class="form-row">
<div
v-for="field in row.fields"
:key="field.name"
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
>
<div class="form-field">
<!-- Field Label -->
<label :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required-indicator">*</span>
</label>
<div
v-for="row in groupedFields"
:key="`row-${row.rowIndex}`"
class="form-row"
>
<div
v-for="field in row.fields"
:key="field.name"
:class="`form-column-${Math.min(Math.max(field.columns || 12, 1), 12)}`"
>
<div class="form-field">
<!-- Field Label -->
<label :for="field.name" class="field-label">
{{ field.label }}
<span v-if="field.required" class="required-indicator">*</span>
</label>
<!-- Help Text -->
<small v-if="field.helpText" class="field-help-text">
{{ field.helpText }}
</small>
<!-- Help Text -->
<small v-if="field.helpText" class="field-help-text">
{{ field.helpText }}
</small>
<!-- Data/Text Field -->
<template v-if="field.type === 'Data' || field.type === 'Text'">
@ -211,10 +214,9 @@
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
<div v-else class="error-container">
@ -573,7 +575,7 @@ const loadDoctypeOptions = async () => {
for (const field of fieldsWithDoctype) {
try {
// Use the new API method for fetching docs
let docs = await Api.getQuotationItems();
let docs = await Api.getQuotationItems(props.projectTemplate);
// Deduplicate by value field
const valueField = field.doctypeValueField || 'name';