add history component
This commit is contained in:
parent
b8fea2c9ca
commit
58e69596bb
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
59
custom_ui/api/db/general.py
Normal file
59
custom_ui/api/db/general.py
Normal 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
|
||||
)
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
260
frontend/src/components/common/DocHistory.vue
Normal file
260
frontend/src/components/common/DocHistory.vue
Normal 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>
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user