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

@ -4,7 +4,7 @@ from custom_ui.api.db.general import get_doc_history
from custom_ui.db_utils import DbUtils, process_query_conditions, build_datatable_dict, get_count_or_filters, build_success_response, build_error_response
from werkzeug.wrappers import Response
from custom_ui.api.db.clients import check_if_customer, convert_lead_to_customer
from custom_ui.services import DbService, ClientService, AddressService, ContactService
from custom_ui.services import DbService, ClientService, AddressService, ContactService, EstimateService, ItemService
# ===============================================================================
# ESTIMATES & INVOICES API METHODS
@ -86,11 +86,25 @@ def get_estimate_table_data(filters={}, sortings=[], page=1, page_size=10):
@frappe.whitelist()
def get_quotation_items():
def get_quotation_items(project_template:str = None):
"""Get all available quotation items."""
try:
items = frappe.get_all("Item", fields=["*"], filters={"item_group": "SNW-S"})
return build_success_response(items)
filters = EstimateService.map_project_template_to_filter(project_template)
items = frappe.get_all("Item", fields=["item_code", "item_group"], filters=filters)
grouped_item_dicts = {}
for item in items:
item_dict = ItemService.get_full_dict(item.item_code)
if item_dict["bom"]:
if "Packages" not in grouped_item_dicts:
grouped_item_dicts["Packages"] = {}
if item.item_group not in grouped_item_dicts["Packages"]:
grouped_item_dicts["Packages"][item.item_group] = []
grouped_item_dicts["Packages"][item.item_group].append(item_dict)
else:
if item.item_group not in grouped_item_dicts:
grouped_item_dicts[item.item_group] = []
grouped_item_dicts[item.item_group].append(item_dict)
return build_success_response(grouped_item_dicts)
except Exception as e:
return build_error_response(str(e), 500)

View File

@ -1,7 +1,8 @@
import frappe
import json
from frappe.utils.data import flt
from custom_ui.services import DbService, StripeService
from custom_ui.services import DbService, StripeService, PaymentService
from custom_ui.models import PaymentData
@frappe.whitelist(allow_guest=True)
def half_down_stripe_payment(sales_order):
@ -31,37 +32,36 @@ def stripe_webhook():
sig_header = frappe.request.headers.get('Stripe-Signature')
session, metadata = StripeService.get_session_and_metadata(payload, sig_header)
required_keys = ["order_num", "company", "payment_type"]
for key in required_keys:
if not metadata.get(key):
raise frappe.ValidationError(f"Missing required metadata key: {key}")
if DbService.exists("Payment Entry", {"reference_no": session.id}):
raise frappe.ValidationError("Payment Entry already exists for this session.")
reference_doctype = "Sales Invoice"
reference_doctype = "Sales Invoice"
if metadata.get("payment_type") == "advance":
reference_doctype = "Sales Order"
elif metadata.get("payment_type") != "full":
raise frappe.ValidationError("Invalid payment type in metadata.")
amount_paid = flt(session.amount_total) / 100
currency = session.currency.upper()
reference_doc = frappe.get_doc(reference_doctype, metadata.get("order_num"))
pe = frappe.get_doc({
"doctype": "Payment Entry",
"payment_type": "Receive",
"party_type": "Customer",
"mode_of_payment": "Stripe",
"party": reference_doc.customer,
"party_name": reference_doc.customer,
"paid_to": metadata.get("company"),
"reference_no": session.id,
"reference_date": frappe.utils.nowdate(),
"reference_doctype": reference_doctype,
"reference_name": reference_doc.name,
"paid_amount": amount_paid,
"paid_currency": currency,
})
# stripe_settings = StripeService.get_stripe_settings(metadata.get("company"))
pe.insert()
pe = PaymentService.create_payment_entry(
data=PaymentData(
mode_of_payment="Stripe",
reference_no=session.id,
reference_date=session.created,
received_amount=amount_paid,
company=metadata.get("company"),
reference_doc_name=metadata.get("order_num")
)
)
pe.submit()
return "Payment Entry created and submitted successfully."

View File

@ -24,6 +24,8 @@ def after_install():
create_task_types()
# create_tasks()
create_bid_meeting_note_form_templates()
create_accounts()
init_stripe_accounts()
build_frontend()
def after_migrate():
@ -42,6 +44,8 @@ def after_migrate():
create_task_types()
# create_tasks()
create_bid_meeting_note_form_templates()
create_accounts()
init_stripe_accounts()
# update_address_fields()
# build_frontend()
@ -1378,3 +1382,58 @@ def create_bid_meeting_note_form_templates():
)
doc.insert(ignore_permissions=True)
def create_accounts():
"""Create necessary accounts if they do not exist."""
print("\n🔧 Checking for necessary accounts...")
accounts = [
{
"Sprinklers Northwest": [
{
"account_name": "Stripe Clearing - Sprinklers Northwest",
"account_type": "Bank",
"parent_account": "Bank Accounts - S",
"company": "Sprinklers Northwest"
}
]
}
]
for company_accounts in accounts:
for company, account_list in company_accounts.items():
for account in account_list:
# Idempotency check
if frappe.db.exists("Account", {"account_name": account["account_name"], "company": account["company"]}):
continue
doc = frappe.get_doc({
"doctype": "Account",
"account_name": account["account_name"],
"account_type": account["account_type"],
"company": account["company"],
"parent_account": account["parent_account"],
"is_group": 0
})
doc.insert(ignore_permissions=True)
frappe.db.commit()
def init_stripe_accounts():
"""Initializes the bare configurations for each Stripe Settings doctypes."""
print("\n🔧 Initializing Stripe Settings for companies...")
companies = ["Sprinklers Northwest"]
for company in companies:
if not frappe.db.exists("Stripe Settings", {"company": company}):
doc = frappe.get_doc({
"doctype": "Stripe Settings",
"company": company,
"api_key": "",
"publishable_key": "",
"webhook_secret": "",
"account": f"Stripe Clearing - {company}"
})
doc.insert(ignore_permissions=True)
frappe.db.commit()

View File

@ -0,0 +1 @@
from .payments import PaymentData

View File

@ -0,0 +1,10 @@
from dataclasses import dataclass
@dataclass
class PaymentData:
mode_of_payment: str
reference_no: str
reference_date: str
received_amount: float
company: str = None
reference_doc_name: str = None

View File

@ -6,4 +6,6 @@ from .estimate_service import EstimateService
from .onsite_meeting_service import OnSiteMeetingService
from .task_service import TaskService
from .service_appointment_service import ServiceAppointmentService
from .stripe_service import StripeService
from .stripe_service import StripeService
from .payment_service import PaymentService
from .item_service import ItemService

View File

@ -1,4 +1,5 @@
import frappe
from .item_service import ItemService
class EstimateService:
@ -93,4 +94,18 @@ class EstimateService:
estimate_doc.customer = customer_name
estimate_doc.save(ignore_permissions=True)
print(f"DEBUG: Linked Quotation {estimate_doc.name} to {customer_type} {customer_name}")
@staticmethod
def map_project_template_to_filter(project_template: str = None) -> dict | None:
"""Map a project template to a filter."""
print(f"DEBUG: Mapping project template {project_template} to quotation category")
if not project_template:
print("DEBUG: No project template provided, defaulting to 'General'")
return None
mapping = {
# SNW Install is both Irrigation and SNW-S categories
"SNW Install": ["in", ["Irrigation", "SNW-S", "Landscaping"]],
}
category = mapping.get(project_template, "General")
print(f"DEBUG: Mapped to quotation category: {category}")
return { "item_group": category }

View File

@ -0,0 +1,38 @@
import frappe
class ItemService:
@staticmethod
def get_item_category(item_code: str) -> str:
"""Retrieve the category of an Item document by item code."""
print(f"DEBUG: Getting category for Item {item_code}")
category = frappe.db.get_value("Item", item_code, "item_group")
print(f"DEBUG: Retrieved category: {category}")
return category
@staticmethod
def get_full_dict(item_code: str) -> frappe._dict:
"""Retrieve the full Item document by item code."""
print(f"DEBUG: Getting full document for Item {item_code}")
item_doc = frappe.get_doc("Item", item_code).as_dict()
item_doc["bom"] = ItemService.get_full_bom_dict(item_code) if item_doc.get("default_bom") else None
return item_doc
@staticmethod
def get_full_bom_dict(item_code: str):
"""Retrieve the Bill of Materials (BOM) associated with an Item."""
print(f"DEBUG: Getting BOM for Item {item_code}")
bom_name = frappe.db.get_value("BOM", {"item": item_code, "is_active": 1}, "name")
bom_dict = frappe.get_doc("BOM", bom_name).as_dict()
for item in bom_dict.get('exploded_items', []):
item_bom_name = frappe.get_value("Item", item["item_name"], "default_bom")
item["bom"] = frappe.get_doc("BOM", item_bom_name).as_dict() if item_bom_name else None
return bom_dict
@staticmethod
def exists(item_code: str) -> bool:
"""Check if an Item document exists by item code."""
print(f"DEBUG: Checking existence of Item {item_code}")
exists = frappe.db.exists("Item", item_code) is not None
print(f"DEBUG: Item {item_code} exists: {exists}")
return exists

View File

@ -1,29 +1,51 @@
import frappe
from custom_ui.services import DbService
from custom_ui.services import DbService, StripeService
from dataclasses import dataclass
from custom_ui.models import PaymentData
class PaymentService:
@staticmethod
def create_payment_entry(reference_doctype: str, reference_doc_name: str, data: dict) -> frappe._dict:
def create_payment_entry(data: PaymentData) -> frappe._dict:
"""Create a Payment Entry document based on the reference document."""
print(f"DEBUG: Creating Payment Entry for {reference_doctype} {reference_doc_name} with data: {data}")
reference_doc = DbService.get_or_throw(reference_doctype, reference_doc_name)
print(f"DEBUG: Creating Payment Entry for {data.reference_doc_name} with data: {data}")
reference_doctype = PaymentService.determine_reference_doctype(data.reference_doc_name)
reference_doc = DbService.get_or_throw(reference_doctype, data.reference_doc_name)
account = StripeService.get_stripe_settings(data.company).account
pe = frappe.get_doc({
"doctype": "Payment Entry",
"company": data.company,
"payment_type": "Receive",
"party_type": "Customer",
"mode_of_payment": data.get("mode_of_payment", "Stripe"),
"mode_of_payment": data.mode_of_payment or "Stripe",
"party": reference_doc.customer,
"party_name": reference_doc.customer,
"paid_to": data.get("paid_to"),
"reference_no": data.get("reference_no"),
"reference_date": data.get("reference_date", frappe.utils.nowdate()),
"reference_doctype": reference_doctype,
"paid_to": account,
"reference_no": data.reference_no,
"reference_date": data.reference_date or frappe.utils.nowdate(),
"received_amount": data.received_amount,
"paid_currency": "USD",
"received_currency": "USD",
}).append("references", {
"reference_doctype": reference_doc.doctype,
"reference_name": reference_doc.name,
"paid_amount": data.get("paid_amount"),
"paid_currency": data.get("paid_currency"),
"reconcile_effect_on": reference_doc.doctype,
"allocated_amount": data.received_amount,
})
pe.insert()
print(f"DEBUG: Created Payment Entry with name: {pe.name}")
return pe.as_dict()
return pe
@staticmethod
def determine_reference_doctype(reference_doc_name: str) -> str:
"""Determine the reference doctype based on the document name pattern."""
print(f"DEBUG: Determining reference doctype for document name: {reference_doc_name}")
if DbService.exists("Sales Order", reference_doc_name):
return "Sales Order"
elif DbService.exists("Sales Invoice", reference_doc_name):
return "Sales Invoice"
else:
frappe.throw("Unable to determine reference doctype from document name.")

View File

@ -1,7 +1,2 @@
import frappe
class SalesOrderService:
@staticmethod
def apply_advance_payment(sales_order_name: str, payment_entry_doc):
pass

View File

@ -0,0 +1,8 @@
services:
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog
ports:
- "8025:8025" # MailHog web UI
- "1025:1025" # SMTP server
restart: unless-stopped

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';

115
stripe-init-webhook.sh Normal file
View File

@ -0,0 +1,115 @@
#!/bin/bash
# Script to initialize and run Stripe CLI webhook forwarding
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
set -e
# Default values
SITE=""
PORT="8000"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--site)
SITE="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 --site <site> [--port <port>]"
echo ""
echo "Options:"
echo " --site Required. The site domain (e.g., erp.local)"
echo " --port Optional. The port number (default: 8000)"
echo ""
echo "Example:"
echo " $0 --site erp.local --port 8000"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check if required flag is provided
if [ -z "$SITE" ]; then
echo "Error: --site flag is required"
echo "Usage: $0 --site <site> [--port <port>]"
exit 1
fi
echo "Checking Stripe CLI installation..."
# Check if Stripe CLI is installed
if ! command -v stripe &> /dev/null; then
echo "Stripe CLI is not installed."
read -p "Would you like to install it now? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Installing Stripe CLI..."
# Add GPG key
echo "Adding Stripe GPG key..."
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
# Add repository
echo "Adding Stripe repository..."
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
# Update and install
echo "Updating package list..."
sudo apt update
echo "Installing Stripe CLI..."
sudo apt install stripe -y
echo "Stripe CLI installed successfully!"
else
echo "Installation cancelled. Exiting."
exit 1
fi
else
echo "Stripe CLI is already installed."
fi
# Check if Stripe CLI is authenticated
echo "Checking authentication status..."
if ! stripe config --list &> /dev/null; then
echo "Stripe CLI is not authenticated."
echo "Please log in to your Stripe account..."
stripe login --interactive
else
# Try to verify authentication by running a simple command
if stripe config --list | grep -q "test_mode_api_key"; then
echo "Stripe CLI is authenticated."
else
echo "Stripe CLI authentication may be invalid."
echo "Please log in to your Stripe account..."
stripe login --interactive
fi
fi
# Start Docker containers
echo ""
echo "Starting Docker containers..."
docker compose -f docker-compose.local.yaml up -d
# Start listening for webhooks
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
echo ""
echo "Starting Stripe webhook listener..."
echo "Forwarding to: $WEBHOOK_URL"
echo ""
echo "Press Ctrl+C to stop"
echo ""
stripe listen --forward-to "$WEBHOOK_URL"

150
stripe-local-init.sh Executable file
View File

@ -0,0 +1,150 @@
#!/bin/bash
# Script to initialize and run Stripe CLI webhook forwarding
# Usage: ./stripe-init-webhook.sh --site <site> --port <port>
set -e
# Colors for output
BLUE='\033[0;34m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Display banner
echo ""
echo -e "${BLUE}+-----------------------------------------------+${NC}"
echo -e "${BLUE} ██████╗ ██╗ ██╗██╗██╗ ██████╗ ██╗ ██╗${NC}"
echo -e "${BLUE} ██╔════╝ ██║ ██║██║██║ ██╔═══██╗██║ ██║${NC}"
echo -e "${BLUE} ╚█████╗ ███████║██║██║ ██║ ██║███████║${NC}"
echo -e "${BLUE} ╚═══██╗ ██╔══██║██║██║ ██║ ██║██╔══██║${NC}"
echo -e "${BLUE} ██████╔╝ ██║ ██║██║███████╗╚██████╔╝██║ ██║${NC}"
echo -e "${BLUE} ╚═════╝ ╚═╝ ╚═╝╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝${NC}"
echo -e "${BLUE}+-----------------------------------------------+${NC}"
echo -e "${YELLOW} 🚀 Automated Local Stripe Environment${NC}"
echo -e "${YELLOW} 💼 For ERPNext Development${NC}"
echo -e "${BLUE}+-----------------------------------------------+${NC}"
echo ""
# Default values
SITE=""
PORT="8000"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--site)
SITE="$2"
shift 2
;;
--port)
PORT="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 --site <site> [--port <port>]"
echo ""
echo "Options:"
echo " --site Required. The site domain (e.g., erp.local)"
echo " --port Optional. The port number (default: 8000)"
echo ""
echo "Example:"
echo " $0 --site erp.local --port 8000"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
# Check if required flag is provided
if [ -z "$SITE" ]; then
echo -e "${RED}❌ Error: --site flag is required${NC}"
echo "Usage: $0 --site <site> [--port <port>]"
exit 1
fi
echo -e "${BLUE}🔍 Checking Stripe CLI installation...${NC}"
# Check if Stripe CLI is installed
if ! command -v stripe &> /dev/null; then
echo -e "${YELLOW}⚠️ Stripe CLI is not installed.${NC}"
read -p "Would you like to install it now? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${BLUE}📦 Installing Stripe CLI...${NC}"
# Add GPG key
echo -e "${BLUE}🔑 Adding Stripe GPG key...${NC}"
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg > /dev/null
# Add repository
echo -e "${BLUE}📚 Adding Stripe repository...${NC}"
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
# Update and install
echo -e "${BLUE}🔄 Updating package list...${NC}"
sudo apt update
echo -e "${BLUE}⬇️ Installing Stripe CLI...${NC}"
sudo apt install stripe -y
echo -e "${GREEN}✅ Stripe CLI installed successfully!${NC}"
else
echo -e "${RED}❌ Installation cancelled. Exiting.${NC}"
exit 1
fi
else
echo -e "${GREEN}✅ Stripe CLI is already installed.${NC}"
fi
# Check if Stripe CLI is authenticated
echo -e "${BLUE}🔐 Checking authentication status...${NC}"
if ! stripe config --list &> /dev/null; then
echo -e "${YELLOW}⚠️ Stripe CLI is not authenticated.${NC}"
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
stripe login --interactive
else
# Try to verify authentication by running a simple command
if stripe config --list | grep -q "test_mode_api_key"; then
echo -e "${GREEN}✅ Stripe CLI is authenticated.${NC}"
else
echo -e "${YELLOW}⚠️ Stripe CLI authentication may be invalid.${NC}"
echo -e "${BLUE}🔑 Please log in to your Stripe account...${NC}"
stripe login --interactive
fi
fi
# Start Docker containers
echo ""
echo -e "${BLUE}🐳 Starting Docker containers...${NC}"
docker compose -f docker-compose.local.yaml up -d
# Cleanup function to run on exit
cleanup() {
echo ""
echo -e "${YELLOW}🛑 Shutting down...${NC}"
echo -e "${BLUE}🐳 Stopping Docker containers...${NC}"
docker compose -f docker-compose.local.yaml down
echo -e "${GREEN}✨ Cleanup complete. Goodbye!${NC}"
exit 0
}
# Trap Ctrl+C (SIGINT) and termination signals
trap cleanup SIGINT SIGTERM EXIT
# Start listening for webhooks
WEBHOOK_URL="http://${SITE}:${PORT}/api/method/custom_ui.api.public.payments.stripe_webhook"
echo ""
echo -e "${GREEN}🎧 Starting Stripe webhook listener...${NC}"
echo -e "${BLUE}📡 Forwarding to: ${YELLOW}$WEBHOOK_URL${NC}"
echo ""
echo -e "${YELLOW}⌨️ Press Ctrl+C to stop${NC}"
echo ""
stripe listen --forward-to "$WEBHOOK_URL"