editing quotation
This commit is contained in:
parent
3360fbcad2
commit
cbc82a08cc
Binary file not shown.
Binary file not shown.
287
core/templates/core/quotation_edit.html
Normal file
287
core/templates/core/quotation_edit.html
Normal file
@ -0,0 +1,287 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n l10n %}
|
||||
|
||||
{% block title %}{% trans "Edit Quotation" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4" id="quotationApp">
|
||||
<div class="row">
|
||||
<!-- Main Form -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-header bg-white border-0 pt-4 px-4">
|
||||
<h5 class="fw-bold mb-0"><i class="bi bi-pencil-square me-2 text-primary"></i>{% trans "Edit Quotation" %}: [[ quotationNumber ]]</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<!-- Customer & Quotation Info -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "Customer" %}</label>
|
||||
<select class="form-select rounded-3 shadow-none border-secondary-subtle" v-model="customerId">
|
||||
<option value="">{% trans "Walking Customer / Guest" %}</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}">{{ customer.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "Quotation #" %}</label>
|
||||
<input type="text" class="form-control rounded-3 shadow-none border-secondary-subtle" v-model="quotationNumber" placeholder="{% trans 'e.g. QUO-1001' %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Search Products" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light border-end-0 border-secondary-subtle"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control rounded-3 border-start-0 border-secondary-subtle shadow-none" placeholder="{% trans 'Search by Name or SKU...' %}" v-model="searchQuery" @input="filterProducts">
|
||||
</div>
|
||||
|
||||
<div class="position-relative">
|
||||
<div class="list-group position-absolute w-100 shadow rounded-3 mt-1" style="z-index: 1000;" v-if="filteredProducts.length > 0">
|
||||
<button v-for="product in filteredProducts" :key="product.id" class="list-group-item list-group-item-action border-0 py-3" @click="addItem(product)">
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<span class="fw-bold">[[ product.name_en ]]</span> / [[ product.name_ar ]]
|
||||
<div class="text-muted small">SKU: [[ product.sku ]] | Stock: [[ product.stock ]]</div>
|
||||
</div>
|
||||
<div class="text-primary fw-bold">[[ currencySymbol ]][[ product.price ]]</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead class="bg-light-subtle">
|
||||
<tr class="small text-uppercase text-muted fw-bold">
|
||||
<th style="width: 40%;">{% trans "Product" %}</th>
|
||||
<th class="text-center">{% trans "Unit Price" %}</th>
|
||||
<th class="text-center" style="width: 15%;">{% trans "Quantity" %}</th>
|
||||
<th class="text-end">{% trans "Total" %}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in cart" :key="index">
|
||||
<td>
|
||||
<div class="fw-bold">[[ item.name_en ]]</div>
|
||||
<div class="text-muted small">[[ item.sku ]]</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.001" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" v-model="item.price" @input="calculateTotal">
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" class="form-control form-control-sm text-center border-0 border-bottom rounded-0" step="0.01" v-model="item.quantity" @input="calculateTotal">
|
||||
</td>
|
||||
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(decimalPlaces) ]]</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-link text-danger p-0" @click="removeItem(index)"><i class="bi bi-x-circle"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="cart.length === 0">
|
||||
<td colspan="5" class="text-center py-5 text-muted">
|
||||
{% trans "Search and add products to this quotation." %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Terms and Conditions" %}</label>
|
||||
<textarea class="form-control rounded-3" rows="4" v-model="termsAndConditions" placeholder="{% trans 'Enter quotation terms, delivery info, etc.' %}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quotation Summary -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card border-0 shadow-sm rounded-4 sticky-top" style="top: 20px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-4">{% trans "Quotation Summary" %}</h5>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">{% trans "Subtotal" %}</span>
|
||||
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">{% trans "Discount" %}</span>
|
||||
<div class="input-group input-group-sm" style="width: 120px;">
|
||||
<input type="number" class="form-control text-end border-0 border-bottom rounded-0" v-model="discount" @input="calculateTotal">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<div class="d-flex justify-content-between mb-4">
|
||||
<h4 class="fw-bold mb-0">{% trans "Grand Total" %}</h4>
|
||||
<h4 class="fw-bold text-primary mb-0">[[ currencySymbol ]][[ grandTotal.toFixed(decimalPlaces) ]]</h4>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Valid Until" %}</label>
|
||||
<input type="date" class="form-control rounded-3" v-model="validUntil">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Notes" %}</label>
|
||||
<textarea class="form-control rounded-3" rows="2" v-model="notes"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-primary rounded-3 py-3 fw-bold shadow-sm" :disabled="isProcessing || cart.length === 0" @click="saveQuotation">
|
||||
<span v-if="isProcessing" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i class="bi bi-check-circle me-2" v-else></i>
|
||||
{% trans "Update Quotation" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue.js 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
{% localize off %}
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
data() {
|
||||
return {
|
||||
products: [
|
||||
{% for p in products %}
|
||||
{
|
||||
id: {{ p.id|default:0 }},
|
||||
name_en: "{{ p.name_en|escapejs }}",
|
||||
name_ar: "{{ p.name_ar|escapejs }}",
|
||||
sku: "{{ p.sku|escapejs }}",
|
||||
price: {{ p.price|default:0 }},
|
||||
stock: {{ p.stock_quantity|default:0 }}
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
searchQuery: '',
|
||||
filteredProducts: [],
|
||||
cart: [
|
||||
{% for item in quotation.items.all %}
|
||||
{
|
||||
id: {{ item.product.id }},
|
||||
name_en: "{{ item.product.name_en|escapejs }}",
|
||||
sku: "{{ item.product.sku|escapejs }}",
|
||||
price: {{ item.unit_price|unlocalize }},
|
||||
quantity: {{ item.quantity|unlocalize }}
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
customerId: '{{ quotation.customer.id|default:"" }}',
|
||||
quotationNumber: '{{ quotation.quotation_number|default:""|escapejs }}',
|
||||
discount: {{ quotation.discount|default:0|unlocalize }},
|
||||
validUntil: '{{ quotation.valid_until|date:"Y-m-d" }}',
|
||||
termsAndConditions: `{{ quotation.terms_and_conditions|default:""|escapejs }}`,
|
||||
notes: `{{ quotation.notes|default:""|escapejs }}`,
|
||||
currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
|
||||
decimalPlaces: {{ decimal_places|default:3 }},
|
||||
isProcessing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
subtotal() {
|
||||
return this.cart.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
},
|
||||
grandTotal() {
|
||||
return Math.max(0, this.subtotal - this.discount);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
filterProducts() {
|
||||
if (this.searchQuery.length > 1) {
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
this.filteredProducts = this.products.filter(p =>
|
||||
p.name_en.toLowerCase().includes(query) ||
|
||||
p.sku.toLowerCase().includes(query) ||
|
||||
p.name_ar.includes(query)
|
||||
).slice(0, 5);
|
||||
} else {
|
||||
this.filteredProducts = [];
|
||||
}
|
||||
},
|
||||
addItem(product) {
|
||||
const existing = this.cart.find(item => item.id === product.id);
|
||||
if (existing) {
|
||||
existing.quantity++;
|
||||
} else {
|
||||
this.cart.push({
|
||||
id: product.id,
|
||||
name_en: product.name_en,
|
||||
sku: product.sku,
|
||||
price: product.price,
|
||||
quantity: 1
|
||||
});
|
||||
}
|
||||
this.searchQuery = '';
|
||||
this.filteredProducts = [];
|
||||
},
|
||||
removeItem(index) {
|
||||
this.cart.splice(index, 1);
|
||||
},
|
||||
saveQuotation() {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
|
||||
const payload = {
|
||||
customer_id: this.customerId,
|
||||
quotation_number: this.quotationNumber,
|
||||
items: this.cart.map(item => ({
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
line_total: item.price * item.quantity
|
||||
})),
|
||||
total_amount: this.grandTotal,
|
||||
discount: this.discount,
|
||||
valid_until: this.validUntil,
|
||||
terms_and_conditions: this.termsAndConditions,
|
||||
notes: this.notes
|
||||
};
|
||||
|
||||
fetch("{% url 'update_quotation_api' quotation.id %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = "{% url 'quotations' %}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert("An unexpected error occurred.");
|
||||
this.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}).mount('#quotationApp');
|
||||
</script>
|
||||
{% endlocalize %}
|
||||
{% endblock %}
|
||||
@ -72,6 +72,11 @@
|
||||
<td>{{ q.valid_until|date:"Y-m-d"|default:"-" }}</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group shadow-sm rounded-3">
|
||||
{% if q.status != 'converted' %}
|
||||
<a href="{% url 'quotation_edit' q.id %}" class="btn btn-sm btn-white border text-primary" title="{% trans 'Edit' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-sm btn-white border text-success"
|
||||
onclick="openWhatsAppModal('{{ q.id }}', '{{ q.customer.phone|default:'' }}', '{{ q.quotation_number|default:q.id }}')"
|
||||
title="{% trans 'Send via WhatsApp' %}">
|
||||
|
||||
@ -40,11 +40,13 @@ urlpatterns = [
|
||||
# Quotations
|
||||
path('quotations/', views.quotations, name='quotations'),
|
||||
path('quotations/create/', views.quotation_create, name='quotation_create'),
|
||||
path('quotations/edit/<int:pk>/', views.quotation_edit, name='quotation_edit'),
|
||||
path('quotations/<int:pk>/', views.quotation_detail, name='quotation_detail'),
|
||||
path('quotations/download-pdf/<int:pk>/', views.download_quotation_pdf, name='download_quotation_pdf'),
|
||||
path('quotations/convert/<int:pk>/', views.convert_quotation_to_invoice, name='convert_quotation_to_invoice'),
|
||||
path('quotations/delete/<int:pk>/', views.delete_quotation, name='delete_quotation'),
|
||||
path('api/create-quotation/', views.create_quotation_api, name='create_quotation_api'),
|
||||
path('api/update-quotation/<int:pk>/', views.update_quotation_api, name='update_quotation_api'),
|
||||
|
||||
# Sales Returns
|
||||
path('sales/returns/', views.sales_returns, name='sales_returns'),
|
||||
|
||||
@ -650,6 +650,21 @@ def quotation_create(request):
|
||||
products = Product.objects.filter(is_active=True)
|
||||
return render(request, 'core/quotation_create.html', {'customers': customers, 'products': products})
|
||||
|
||||
@login_required
|
||||
def quotation_edit(request, pk):
|
||||
quotation = get_object_or_404(Quotation, pk=pk)
|
||||
if quotation.status == 'converted':
|
||||
messages.error(request, _("Cannot edit a converted quotation."))
|
||||
return redirect('quotations')
|
||||
|
||||
customers = Customer.objects.all()
|
||||
products = Product.objects.filter(is_active=True)
|
||||
return render(request, 'core/quotation_edit.html', {
|
||||
'quotation': quotation,
|
||||
'customers': customers,
|
||||
'products': products
|
||||
})
|
||||
|
||||
@login_required
|
||||
def quotation_detail(request, pk):
|
||||
quotation = get_object_or_404(Quotation, pk=pk)
|
||||
@ -750,6 +765,66 @@ def create_quotation_api(request):
|
||||
logger.error(f"Error creating quotation: {str(e)}")
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
@login_required
|
||||
def update_quotation_api(request, pk):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'error': 'Method not allowed'})
|
||||
|
||||
quotation = get_object_or_404(Quotation, pk=pk)
|
||||
if quotation.status == 'converted':
|
||||
return JsonResponse({'success': False, 'error': _('Cannot update a converted quotation.')})
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
customer_id = data.get('customer_id')
|
||||
quotation_number = data.get('quotation_number')
|
||||
items = data.get('items', [])
|
||||
total_amount = data.get('total_amount', 0)
|
||||
discount = data.get('discount', 0)
|
||||
valid_until = data.get('valid_until')
|
||||
terms_and_conditions = data.get('terms_and_conditions', '')
|
||||
notes = data.get('notes', '')
|
||||
|
||||
if not items:
|
||||
return JsonResponse({'success': False, 'error': _('Cannot save an empty quotation.')})
|
||||
|
||||
with transaction.atomic():
|
||||
customer = None
|
||||
if customer_id:
|
||||
customer = Customer.objects.get(id=customer_id)
|
||||
|
||||
quotation.customer = customer
|
||||
quotation.quotation_number = quotation_number
|
||||
quotation.total_amount = total_amount
|
||||
quotation.discount = discount
|
||||
quotation.valid_until = valid_until if valid_until else None
|
||||
quotation.terms_and_conditions = terms_and_conditions
|
||||
quotation.notes = notes
|
||||
quotation.save()
|
||||
|
||||
# Remove old items and add new ones
|
||||
quotation.items.all().delete()
|
||||
for item in items:
|
||||
product = Product.objects.get(id=item['id'])
|
||||
QuotationItem.objects.create(
|
||||
quotation=quotation,
|
||||
product=product,
|
||||
quantity=item['quantity'],
|
||||
unit_price=item['price'],
|
||||
line_total=item['line_total']
|
||||
)
|
||||
|
||||
messages.success(request, _("Quotation updated successfully."))
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
except Customer.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': _('Customer not found.')})
|
||||
except Product.DoesNotExist:
|
||||
return JsonResponse({'success': False, 'error': _('One or more products not found.')})
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating quotation: {str(e)}")
|
||||
return JsonResponse({'success': False, 'error': str(e)})
|
||||
|
||||
# --- Sales Returns ---
|
||||
|
||||
@login_required
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user