add history component

This commit is contained in:
Casey 2025-12-30 12:33:29 -06:00
parent b8fea2c9ca
commit 58e69596bb
8 changed files with 404 additions and 58 deletions

View File

@ -130,7 +130,8 @@ def get_client(client_name):
clientData["contacts"].append(linked_doc.as_dict())
elif link["link_doctype"] == "Address":
clientData["addresses"].append(linked_doc.as_dict())
# TODO: Continue getting other linked docs like jobs, invoices, etc.
doctypes_to_fetch_history = []
# TODO: Continue getting other linked docs like jobs, invoices, etc.
print("DEBUG: Final client data prepared:", clientData)
return build_success_response(clientData)
except frappe.ValidationError as ve:

View File

@ -1,5 +1,6 @@
import frappe, json
from frappe.utils.pdf import get_pdf
from custom_ui.api.db.general import get_doc_history
from custom_ui.db_utils import 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
@ -95,6 +96,8 @@ def get_estimate(estimate_name):
address_doc["contacts"] = contacts
est_dict["address_details"] = address_doc
est_dict["history"] = get_doc_history("Quotation", estimate_name)
return build_success_response(est_dict)
except Exception as e:
@ -293,8 +296,10 @@ def upsert_estimate(data):
})
estimate.save()
estimate_dict = estimate.as_dict()
estimate_dict["history"] = get_doc_history("Quotation", estimate_name)
print(f"DEBUG: Estimate updated: {estimate.name}")
return build_success_response(estimate.as_dict())
return build_success_response(estimate_dict)
# Otherwise, create new estimate
else:
@ -329,6 +334,11 @@ def upsert_estimate(data):
print(f"DEBUG: Error in upsert_estimate: {str(e)}")
return build_error_response(str(e), 500)
def get_estimate_history(estimate_name):
"""Get the history of changes for a specific estimate."""
return history
# @frappe.whitelist()
# def get_estimate_counts():
# """Get specific counts of estimates based on their status."""

View File

@ -0,0 +1,59 @@
import frappe
from custom_ui.db_utils import build_history_entries
def get_doc_history(doctype, docname):
"""Get the history of changes for a specific document."""
# Fetch comments
comments = frappe.get_all(
"Comment",
filters={
"reference_doctype": doctype,
"reference_name": docname
},
fields=["*"],
order_by="creation desc"
)
versions = frappe.get_all(
"Version",
filters={"docname": docname, "ref_doctype": doctype},
fields=["*"],
order_by="creation desc"
)
history_entries = build_history_entries(comments, versions)
print(f"DEBUG: Retrieved history for {doctype} {docname}: {history_entries}")
return history_entries
def get_docs_history(doctypes_with_names):
"""Get history for multiple documents."""
all_history = {}
for doctype, docname in doctypes_with_names:
history = get_doc_history(doctype, docname)
all_history[f"{doctype}:{docname}"] = history
return all_history
def search_any_field(doctype, text):
meta = frappe.get_meta(doctype)
like = f"%{text}%"
conditions = []
# 1⃣ Explicitly include `name`
conditions.append("`name` LIKE %s")
# 2⃣ Include searchable DocFields
for field in meta.fields:
if field.fieldtype in ("Data", "Small Text", "Text", "Link"):
conditions.append(f"`{field.fieldname}` LIKE %s")
query = f"""
SELECT name
FROM `tab{doctype}`
WHERE {" OR ".join(conditions)}
LIMIT 20
"""
return frappe.db.sql(
query,
[like] * len(conditions),
as_dict=True
)

View File

@ -3,7 +3,7 @@ import os
import subprocess
import frappe
from custom_ui.utils import create_module
from custom_ui.db_utils import search_any_field
from custom_ui.api.db.general import search_any_field
@click.command("update-data")
@click.option("--site", default=None, help="Site to update data for")

View File

@ -1,6 +1,5 @@
import frappe
import json
from frappe.utils import strip_html
def map_field_name(frontend_field):
field_mapping = {
@ -196,30 +195,37 @@ def map_lead_update(client_data):
client_data[lead_field] = client_data[client_field]
return client_data
def search_any_field(doctype, text):
meta = frappe.get_meta(doctype)
like = f"%{text}%"
conditions = []
# 1⃣ Explicitly include `name`
conditions.append("`name` LIKE %s")
# 2⃣ Include searchable DocFields
for field in meta.fields:
if field.fieldtype in ("Data", "Small Text", "Text", "Link"):
conditions.append(f"`{field.fieldname}` LIKE %s")
query = f"""
SELECT name
FROM `tab{doctype}`
WHERE {" OR ".join(conditions)}
LIMIT 20
"""
return frappe.db.sql(
query,
[like] * len(conditions),
as_dict=True
)
def map_comment_to_history_entry(comment):
return {
"type": comment.get("comment_type", "Comment"),
"user": comment.get("owner"),
"timestamp": comment.get("creation"),
"message": strip_html(comment.get("content", ""))
}
def map_version_data_to_history_entry(changed_data, creation, owner):
field, old, new = changed_data
return {
"type": "Field Change",
"timestamp": creation,
"user": owner,
"message": f"Changed '{field}' from '{old}' to '{new}'"
}
def build_history_entries(comments, versions):
history = []
for comment in comments:
history.append(map_comment_to_history_entry(comment))
for version in versions:
data = json.loads(version.get("data", "[]"))
for changed_data in data.get("changed", []):
entry = map_version_data_to_history_entry(
changed_data,
version.get("creation"),
version.get("owner")
)
if entry:
history.append(entry)
# Sort by timestamp descending
history.sort(key=lambda x: x["timestamp"], reverse=True)
return history

View File

@ -0,0 +1,260 @@
<template>
<div class="doc-history-container">
<div class="history-header" @click="toggleHistory">
<div class="header-content">
<i :class="isOpen ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="toggle-icon"></i>
<span class="header-title">History - {{ doctype }}</span>
</div>
<span class="history-count" v-if="events.length">{{ events.length }} events</span>
</div>
<transition name="slide-fade">
<div v-if="isOpen" class="history-content">
<div v-if="events.length === 0" class="no-history">
No history available.
</div>
<div v-else class="history-list">
<div v-for="(group, groupIndex) in groupedEvents" :key="groupIndex" class="history-group">
<div class="history-group-header" @click="toggleGroup(group.timestamp)">
<i :class="expandedGroups[group.timestamp] ? 'pi pi-chevron-down' : 'pi pi-chevron-right'" class="group-toggle-icon"></i>
<span class="history-date">{{ formatDate(group.timestamp) }}</span>
<span class="history-user">{{ group.user }}</span>
<span class="history-types-summary">({{ group.typesDisplay }})</span>
</div>
<div v-if="expandedGroups[group.timestamp]" class="history-group-items">
<div v-for="(event, index) in group.events" :key="index" class="history-item">
<div class="history-meta">
<span class="history-type">{{ event.type }}</span>
</div>
<div class="history-message">
{{ event.message }}
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
events: {
type: Array,
default: () => []
},
doctype: {
type: String,
default: 'Document'
}
});
const isOpen = ref(false);
const expandedGroups = ref({});
const groupedEvents = computed(() => {
const groups = {};
props.events.forEach(event => {
const key = event.timestamp;
if (!groups[key]) {
groups[key] = {
timestamp: event.timestamp,
user: event.user,
events: [],
types: new Set()
};
}
groups[key].events.push(event);
groups[key].types.add(event.type);
});
// Sort descending by timestamp
return Object.values(groups).map(group => ({
...group,
typesDisplay: Array.from(group.types).join(', ')
})).sort((a, b) => {
if (a.timestamp < b.timestamp) return 1;
if (a.timestamp > b.timestamp) return -1;
return 0;
});
});
const toggleHistory = () => {
isOpen.value = !isOpen.value;
};
const toggleGroup = (timestamp) => {
expandedGroups.value[timestamp] = !expandedGroups.value[timestamp];
};
const formatDate = (timestamp) => {
if (!timestamp) return '';
try {
// Handle Frappe/Python timestamp format if needed, but standard Date constructor usually handles ISO-like strings
const date = new Date(timestamp);
return date.toLocaleString();
} catch (e) {
return timestamp;
}
};
</script>
<style scoped>
.doc-history-container {
border: 1px solid #e5e7eb;
border-radius: 6px;
background-color: #fff;
margin-top: 1rem;
overflow: hidden;
width: 100%;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: #f9fafb;
cursor: pointer;
user-select: none;
transition: background-color 0.2s;
}
.history-header:hover {
background-color: #f3f4f6;
}
.header-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-title {
font-weight: 600;
color: #374151;
}
.toggle-icon {
font-size: 0.875rem;
color: #6b7280;
}
.history-count {
font-size: 0.75rem;
color: #6b7280;
background-color: #e5e7eb;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
}
.history-content {
border-top: 1px solid #e5e7eb;
max-height: 400px;
overflow-y: auto;
}
.history-list {
display: flex;
flex-direction: column;
}
.history-group {
border-bottom: 1px solid #e5e7eb;
}
.history-group:last-child {
border-bottom: none;
}
.history-group-header {
padding: 0.5rem 1rem;
background-color: #f9fafb;
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
border-bottom: 1px solid #f3f4f6;
font-weight: 500;
cursor: pointer;
align-items: center;
}
.history-group-header:hover {
background-color: #f3f4f6;
}
.group-toggle-icon {
font-size: 0.75rem;
color: #9ca3af;
}
.history-types-summary {
color: #9ca3af;
font-style: italic;
}
.history-group-items {
display: flex;
flex-direction: column;
background-color: #fff;
}
.history-item {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f3f4f6;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.history-item:last-child {
border-bottom: none;
}
.history-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.75rem;
color: #6b7280;
align-items: center;
}
.history-type {
font-weight: 600;
color: #4b5563;
background-color: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.history-message {
font-size: 0.875rem;
color: #1f2937;
line-height: 1.5;
white-space: pre-wrap;
}
.no-history {
padding: 2rem;
text-align: center;
color: #6b7280;
font-style: italic;
}
/* Transition */
.slide-fade-enter-active,
.slide-fade-leave-active {
transition: all 0.3s ease-out;
}
.slide-fade-enter-from,
.slide-fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@ -76,7 +76,7 @@
:disabled="!isEditable"
showButtons
buttonLayout="horizontal"
@input="updateTotal"
@input="onQtyChange(item)"
class="qty-input"
/>
<span>Price: ${{ (item.standardRate || 0).toFixed(2) }}</span>
@ -90,7 +90,7 @@
locale="en-US"
:min="0"
:disabled="!isEditable"
@input="updateTotal"
@input="updateDiscountFromAmount(item)"
placeholder="$0.00"
class="discount-input"
/>
@ -101,7 +101,7 @@
:min="0"
:max="100"
:disabled="!isEditable"
@input="updateTotal"
@input="updateDiscountFromPercentage(item)"
placeholder="0%"
class="discount-input"
/>
@ -123,7 +123,7 @@
/>
</div>
</div>
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0) - (item.discountType === 'percentage' ? ((item.qty || 0) * (item.standardRate || 0) * ((item.discountPercentage || 0) / 100)) : (item.discountAmount || 0))).toFixed(2) }}</span>
<span>Total: ${{ ((item.qty || 0) * (item.standardRate || 0) - (item.discountAmount || 0)).toFixed(2) }}</span>
<Button
v-if="isEditable"
icon="pi pi-trash"
@ -158,6 +158,11 @@
{{ getResponseText(estimateResponse) }}
</span>
</div>
<DocHistory
v-if="!isNew && estimate && estimate.history"
:events="estimate.history"
doctype="Estimate"
/>
</div>
<!-- Manual Response Modal -->
@ -286,6 +291,7 @@ import { ref, reactive, computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Modal from "../common/Modal.vue";
import DataTable from "../common/DataTable.vue";
import DocHistory from "../common/DocHistory.vue";
import InputText from "primevue/inputtext";
import InputNumber from "primevue/inputnumber";
import Button from "primevue/button";
@ -429,8 +435,26 @@ const clearItems = () => {
selectedItems.value = [];
};
const updateTotal = () => {
// Computed will update
const updateDiscountFromAmount = (item) => {
const total = (item.qty || 0) * (item.standardRate || 0);
if (total === 0) {
item.discountPercentage = 0;
} else {
item.discountPercentage = ((item.discountAmount || 0) / total) * 100;
}
};
const updateDiscountFromPercentage = (item) => {
const total = (item.qty || 0) * (item.standardRate || 0);
item.discountAmount = total * ((item.discountPercentage || 0) / 100);
};
const onQtyChange = (item) => {
if (item.discountType === 'percentage') {
updateDiscountFromPercentage(item);
} else {
updateDiscountFromAmount(item);
}
};
const saveDraft = async () => {
@ -443,8 +467,8 @@ const saveDraft = async () => {
items: selectedItems.value.map((i) => ({
itemCode: i.itemCode,
qty: i.qty,
discountAmount: i.discountType === 'currency' ? i.discountAmount : 0,
discountPercentage: i.discountType === 'percentage' ? i.discountPercentage : 0
discountAmount: i.discountAmount,
discountPercentage: i.discountPercentage
})),
estimateName: formData.estimateName,
requiresHalfPayment: formData.requiresHalfPayment,
@ -509,12 +533,6 @@ const confirmAndSendEstimate = async () => {
const toggleDiscountType = (item, type) => {
item.discountType = type;
if (type === 'currency') {
item.discountPercentage = null;
} else {
item.discountAmount = null;
}
updateTotal();
};
const tableActions = [
@ -531,12 +549,7 @@ const totalCost = computed(() => {
return (selectedItems.value || []).reduce((sum, item) => {
const qty = item.qty || 0;
const rate = item.standardRate || 0;
let discount = 0;
if (item.discountType === 'percentage') {
discount = (qty * rate) * ((item.discountPercentage || 0) / 100);
} else {
discount = item.discountAmount || 0;
}
const discount = item.discountAmount || 0;
return sum + (qty * rate) - discount;
}, 0);
});

View File

@ -163,7 +163,9 @@ const columns = [
type: "status-button",
sortable: true,
buttonVariant: "outlined",
onStatusClick: (status, rowData) => handleEstimateClick(status, rowData),
onStatusClick: (status, rowData) => {
router.push(`/estimate?name=${encodeURIComponent(rowData.id)}`);
},
//disableCondition: (status) => status?.toLowerCase() === "draft",
disableCondition: false
},
@ -188,11 +190,6 @@ const tableActions = [
},
];
const handleEstimateClick = (status, rowData) => {
// Navigate to estimate details page with the name
router.push(`/estimate?name=${encodeURIComponent(rowData.name)}`);
};
const closeSubmitEstimateModal = () => {
showSubmitEstimateModal.value = false;
};