Autosave: 20260202-182538
This commit is contained in:
parent
473f13fb08
commit
34d6321e11
Binary file not shown.
Binary file not shown.
Binary file not shown.
43
core/migrations/0016_expensecategory_expense.py
Normal file
43
core/migrations/0016_expensecategory_expense.py
Normal file
@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-02 17:15
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_userprofile'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ExpenseCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name_en', models.CharField(max_length=100, verbose_name='Name (English)')),
|
||||
('name_ar', models.CharField(max_length=100, verbose_name='Name (Arabic)')),
|
||||
('description', models.TextField(blank=True, verbose_name='Description')),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Expense Categories',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Expense',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=3, max_digits=15, verbose_name='Amount')),
|
||||
('date', models.DateField(default=django.utils.timezone.now, verbose_name='Date')),
|
||||
('description', models.TextField(blank=True, verbose_name='Description')),
|
||||
('attachment', models.FileField(blank=True, null=True, upload_to='expense_attachments/', verbose_name='Attachment')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to=settings.AUTH_USER_MODEL)),
|
||||
('payment_method', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='core.paymentmethod')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='core.expensecategory')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
@ -104,6 +104,30 @@ class PaymentMethod(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.name_en} / {self.name_ar}"
|
||||
|
||||
class ExpenseCategory(models.Model):
|
||||
name_en = models.CharField(_("Name (English)"), max_length=100)
|
||||
name_ar = models.CharField(_("Name (Arabic)"), max_length=100)
|
||||
description = models.TextField(_("Description"), blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = _("Expense Categories")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name_en} / {self.name_ar}"
|
||||
|
||||
class Expense(models.Model):
|
||||
category = models.ForeignKey(ExpenseCategory, on_delete=models.CASCADE, related_name="expenses")
|
||||
amount = models.DecimalField(_("Amount"), max_digits=15, decimal_places=3)
|
||||
date = models.DateField(_("Date"), default=timezone.now)
|
||||
description = models.TextField(_("Description"), blank=True)
|
||||
payment_method = models.ForeignKey(PaymentMethod, on_delete=models.SET_NULL, null=True, blank=True, related_name="expenses")
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="expenses")
|
||||
attachment = models.FileField(_("Attachment"), upload_to="expense_attachments/", blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Expense {self.id} - {self.amount} ({self.category.name_en})"
|
||||
|
||||
class Sale(models.Model):
|
||||
PAYMENT_TYPE_CHOICES = [
|
||||
('cash', _('Cash')),
|
||||
@ -357,4 +381,4 @@ def create_user_profile(sender, instance, created, **kwargs):
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
if not hasattr(instance, 'profile'):
|
||||
UserProfile.objects.create(user=instance)
|
||||
instance.profile.save()
|
||||
instance.profile.save()
|
||||
@ -121,6 +121,26 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- Expenses Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#expensesSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'expenses' or url_name == 'expense_categories' %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<span>{% trans "Expenses" %}</span>
|
||||
<i class="bi bi-chevron-down chevron"></i>
|
||||
</a>
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'expenses' or url_name == 'expense_categories' %}show{% endif %}" id="expensesSubmenu">
|
||||
<li>
|
||||
<a href="{% url 'expenses' %}" class="{% if url_name == 'expenses' %}active{% endif %}">
|
||||
<i class="bi bi-receipt"></i> {% trans "Expense List" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'expense_categories' %}" class="{% if url_name == 'expense_categories' %}active{% endif %}">
|
||||
<i class="bi bi-tags"></i> {% trans "Categories" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- Contacts Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#contactsSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'customers' or url_name == 'suppliers' %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
@ -276,4 +296,4 @@
|
||||
</script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@ -69,6 +69,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=customers %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
202
core/templates/core/expense_categories.html
Normal file
202
core/templates/core/expense_categories.html
Normal file
@ -0,0 +1,202 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Expense Categories" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">{% trans "Expense Categories" %}</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'index' %}">{% trans "Dashboard" %}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{% url 'expenses' %}">{% trans "Expenses" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Categories" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<button class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#addCategoryModal">
|
||||
<i class="bi bi-plus-lg me-2"></i>{% trans "Add Category" %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-3" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans "Category Name" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Expenses Count" %}</th>
|
||||
<th class="text-end pe-4">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<div class="fw-bold">{{ category.name_en }}</div>
|
||||
<div class="text-muted small">{{ category.name_ar }}</div>
|
||||
</td>
|
||||
<td>{{ category.description|default:"-" }}</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-primary rounded-pill px-3">
|
||||
{{ category.expenses.count }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<button class="btn btn-sm btn-light rounded-circle me-1"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#editCategoryModal"
|
||||
data-id="{{ category.id }}"
|
||||
data-name-en="{{ category.name_en }}"
|
||||
data-name-ar="{{ category.name_ar }}"
|
||||
data-description="{{ category.description }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light rounded-circle text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ category.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal{{ category.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body py-4">
|
||||
<p class="text-muted">{% trans "Are you sure you want to delete this category?" %}</p>
|
||||
<h5 class="fw-bold">{{ category.name_en }} / {{ category.name_ar }}</h5>
|
||||
{% if category.expenses.exists %}
|
||||
<div class="alert alert-warning mt-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
{% trans "This category has active expenses. Delete them first." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-light rounded-3" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
{% if not category.expenses.exists %}
|
||||
<a href="{% url 'expense_category_delete' category.pk %}" class="btn btn-danger rounded-3 px-4">{% trans "Delete" %}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-5">
|
||||
<div class="py-4">
|
||||
<i class="bi bi-tags text-muted display-1"></i>
|
||||
<p class="text-muted mt-3">{% trans "No categories found." %}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
<div class="modal fade" id="addCategoryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 rounded-4 shadow">
|
||||
<div class="modal-header p-4 border-0">
|
||||
<h5 class="modal-title fw-bold">{% trans "Add Expense Category" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="{% url 'expense_categories' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Name (English)" %}</label>
|
||||
<input type="text" name="name_en" class="form-control rounded-3" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Name (Arabic)" %}</label>
|
||||
<input type="text" name="name_ar" class="form-control rounded-3" required>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-bold">{% trans "Description" %}</label>
|
||||
<textarea name="description" class="form-control rounded-3" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer p-4 border-0">
|
||||
<button type="button" class="btn btn-light rounded-3 px-4" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="btn btn-primary rounded-3 px-4">{% trans "Save Category" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
<div class="modal fade" id="editCategoryModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 rounded-4 shadow">
|
||||
<div class="modal-header p-4 border-0">
|
||||
<h5 class="modal-title fw-bold">{% trans "Edit Expense Category" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="{% url 'expense_categories' %}" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="category_id" id="edit_category_id">
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Name (English)" %}</label>
|
||||
<input type="text" name="name_en" id="edit_name_en" class="form-control rounded-3" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Name (Arabic)" %}</label>
|
||||
<input type="text" name="name_ar" id="edit_name_ar" class="form-control rounded-3" required>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-bold">{% trans "Description" %}</label>
|
||||
<textarea name="description" id="edit_description" class="form-control rounded-3" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer p-4 border-0">
|
||||
<button type="button" class="btn btn-light rounded-3 px-4" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="btn btn-primary rounded-3 px-4">{% trans "Update Category" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const editCategoryModal = document.getElementById('editCategoryModal');
|
||||
if (editCategoryModal) {
|
||||
editCategoryModal.addEventListener('show.bs.modal', event => {
|
||||
const button = event.relatedTarget;
|
||||
const id = button.getAttribute('data-id');
|
||||
const nameEn = button.getAttribute('data-name-en');
|
||||
const nameAr = button.getAttribute('data-name-ar');
|
||||
const description = button.getAttribute('data-description');
|
||||
|
||||
document.getElementById('edit_category_id').value = id;
|
||||
document.getElementById('edit_name_en').value = nameEn;
|
||||
document.getElementById('edit_name_ar').value = nameAr;
|
||||
document.getElementById('edit_description').value = description;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
220
core/templates/core/expenses.html
Normal file
220
core/templates/core/expenses.html
Normal file
@ -0,0 +1,220 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Expenses" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-1">{% trans "Expenses" %}</h2>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{% url 'index' %}">{% trans "Dashboard" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{% trans "Expenses" %}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary shadow-sm" data-bs-toggle="modal" data-bs-target="#addExpenseModal">
|
||||
<i class="bi bi-plus-lg me-2"></i>{% trans "Record Expense" %}
|
||||
</button>
|
||||
<a href="{% url 'expense_categories' %}" class="btn btn-outline-primary shadow-sm">
|
||||
<i class="bi bi-tags me-2"></i>{% trans "Expense Categories" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-3" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-0 shadow-sm rounded-4 bg-primary text-white">
|
||||
<div class="card-body p-4 text-center">
|
||||
<h6 class="text-white-50 mb-2">{% trans "Total Expenses" %}</h6>
|
||||
<h2 class="fw-bold mb-0">{{ site_settings.currency_symbol }} {{ total_expenses|floatformat:site_settings.decimal_places }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">{% trans "Start Date" %}</label>
|
||||
<input type="date" name="start_date" class="form-control" value="{{ request.GET.start_date }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">{% trans "End Date" %}</label>
|
||||
<input type="date" name="end_date" class="form-control" value="{{ request.GET.end_date }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">{% trans "Category" %}</label>
|
||||
<select name="category" class="form-select">
|
||||
<option value="">{% trans "All Categories" %}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if request.GET.category == category.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ category.name_en }} / {{ category.name_ar }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-light w-100 me-2">{% trans "Filter" %}</button>
|
||||
<a href="{% url 'expenses' %}" class="btn btn-outline-secondary">{% trans "Reset" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expenses Table -->
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans "Date" %}</th>
|
||||
<th>{% trans "Category" %}</th>
|
||||
<th>{% trans "Description" %}</th>
|
||||
<th>{% trans "Payment Method" %}</th>
|
||||
<th>{% trans "Amount" %}</th>
|
||||
<th class="text-end pe-4">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for expense in expenses %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<span class="fw-medium">{{ expense.date|date:"Y-m-d" }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-light text-primary rounded-pill px-3">
|
||||
{{ expense.category.name_en }} / {{ expense.category.name_ar }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-truncate" style="max-width: 250px;">
|
||||
{{ expense.description|default:"-" }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ expense.payment_method.name_en|default:"-" }}</td>
|
||||
<td class="fw-bold">{{ site_settings.currency_symbol }} {{ expense.amount|floatformat:site_settings.decimal_places }}</td>
|
||||
<td class="text-end pe-4">
|
||||
{% if expense.attachment %}
|
||||
<a href="{{ expense.attachment.url }}" target="_blank" class="btn btn-sm btn-light rounded-circle me-1" title="{% trans 'View Attachment' %}">
|
||||
<i class="bi bi-paperclip"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-sm btn-light rounded-circle text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ expense.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade" id="deleteModal{{ expense.id }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 rounded-4">
|
||||
<div class="modal-header border-0">
|
||||
<h5 class="modal-title fw-bold">{% trans "Confirm Delete" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body py-4">
|
||||
<p class="text-muted">{% trans "Are you sure you want to delete this expense record?" %}</p>
|
||||
<h5 class="fw-bold">{{ site_settings.currency_symbol }} {{ expense.amount }} - {{ expense.category.name_en }}</h5>
|
||||
</div>
|
||||
<div class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-light rounded-3" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<a href="{% url 'expense_delete' expense.pk %}" class="btn btn-danger rounded-3 px-4">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center py-5">
|
||||
<div class="py-4">
|
||||
<i class="bi bi-receipt text-muted display-1"></i>
|
||||
<p class="text-muted mt-3">{% trans "No expense records found." %}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=expenses %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Record Expense Modal -->
|
||||
<div class="modal fade" id="addExpenseModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content border-0 rounded-4 shadow">
|
||||
<div class="modal-header p-4 border-0">
|
||||
<h5 class="modal-title fw-bold">{% trans "Record New Expense" %}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form action="{% url 'expense_create' %}" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="modal-body p-4">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Category" %}</label>
|
||||
<select name="category" class="form-select rounded-3" required>
|
||||
<option value="">{% trans "Select Category" %}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}">{{ category.name_en }} / {{ category.name_ar }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Amount" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">{{ site_settings.currency_symbol }}</span>
|
||||
<input type="number" step="0.001" name="amount" class="form-control rounded-end-3" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Date" %}</label>
|
||||
<input type="date" name="date" class="form-control rounded-3" value="{% now 'Y-m-d' %}" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Payment Method" %}</label>
|
||||
<select name="payment_method" class="form-select rounded-3">
|
||||
{% for pm in payment_methods %}
|
||||
<option value="{{ pm.id }}">{{ pm.name_en }} / {{ pm.name_ar }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">{% trans "Description" %}</label>
|
||||
<textarea name="description" class="form-control rounded-3" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label class="form-label fw-bold">{% trans "Attachment" %}</label>
|
||||
<input type="file" name="attachment" class="form-control rounded-3">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer p-4 border-0">
|
||||
<button type="button" class="btn btn-light rounded-3 px-4" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="btn btn-primary rounded-3 px-4">{% trans "Save Expense" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -165,6 +165,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=products %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
306
core/templates/core/invoice_edit.html
Normal file
306
core/templates/core/invoice_edit.html
Normal file
@ -0,0 +1,306 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Edit Invoice" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4" id="saleApp">
|
||||
<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">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="fw-bold mb-0"><i class="bi bi-pencil-square me-2 text-primary"></i>{% trans "Edit Sales Invoice" %} #[[ invoiceNumber || '{{ sale.id }}' ]]</h5>
|
||||
<a href="{% url 'invoices' %}" class="btn btn-sm btn-light rounded-pill px-3">
|
||||
<i class="bi bi-arrow-left me-1"></i> {% trans "Back to List" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<!-- Customer & Invoice 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 "Invoice #" %}</label>
|
||||
<input type="text" class="form-control rounded-3 shadow-none border-secondary-subtle" v-model="invoiceNumber" placeholder="{% trans 'e.g. INV-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" v-model="item.quantity" @input="calculateTotal">
|
||||
</td>
|
||||
<td class="text-end fw-bold">[[ currencySymbol ]][[ (item.price * item.quantity).toFixed(3) ]]</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 invoice." %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice 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 "Invoice 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(3) ]]</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(3) ]]</h4>
|
||||
</div>
|
||||
|
||||
<!-- Payment Details -->
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">{% trans "Payment Type" %}</label>
|
||||
<select class="form-select rounded-3 shadow-none border-secondary-subtle" v-model="paymentType">
|
||||
<option value="cash">{% trans "Cash (Full)" %}</option>
|
||||
<option value="credit">{% trans "Credit (Unpaid)" %}</option>
|
||||
<option value="partial">{% trans "Partial Payment" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="paymentType !== 'credit'">
|
||||
<label class="form-label small fw-bold">{% trans "Payment Method" %}</label>
|
||||
<select class="form-select rounded-3 shadow-none border-secondary-subtle" v-model="paymentMethodId">
|
||||
{% for method in payment_methods %}
|
||||
<option value="{{ method.id }}">{% if LANGUAGE_CODE == 'ar' %}{{ method.name_ar }}{% else %}{{ method.name_en }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="paymentType === 'partial'">
|
||||
<label class="form-label small fw-bold">{% trans "Paid Amount" %}</label>
|
||||
<input type="number" step="0.001" class="form-control rounded-3" v-model="paidAmount">
|
||||
</div>
|
||||
|
||||
<div class="mb-4" v-if="paymentType !== 'cash'">
|
||||
<label class="form-label small fw-bold">{% trans "Due Date" %}</label>
|
||||
<input type="date" class="form-control rounded-3" v-model="dueDate">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Internal 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="saveSale">
|
||||
<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 Invoice" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue.js 3 -->
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
delimiters: ['[[', ']]'],
|
||||
data() {
|
||||
return {
|
||||
products: [
|
||||
{% for p in products %}
|
||||
{
|
||||
id: {{ p.id }},
|
||||
name_en: "{{ p.name_en }}",
|
||||
name_ar: "{{ p.name_ar }}",
|
||||
sku: "{{ p.sku }}",
|
||||
price: {{ p.price }},
|
||||
stock: {{ p.stock_quantity }}
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
searchQuery: '',
|
||||
filteredProducts: [],
|
||||
cart: {{ cart_json|safe }},
|
||||
customerId: '{{ sale.customer_id|default:"" }}',
|
||||
invoiceNumber: '{{ sale.invoice_number|default:"" }}',
|
||||
paymentType: '{{ sale.payment_type }}',
|
||||
paymentMethodId: '{{ payment_method_id }}',
|
||||
paidAmount: {{ sale.paid_amount|default:0 }},
|
||||
discount: {{ sale.discount|default:0 }},
|
||||
dueDate: '{{ sale.due_date|date:"Y-m-d" }}',
|
||||
notes: `{{ sale.notes|escapejs }}`,
|
||||
currencySymbol: '{{ site_settings.currency_symbol }}',
|
||||
isProcessing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
subtotal() {
|
||||
return this.cart.reduce((total, item) => total + (parseFloat(item.price) * parseFloat(item.quantity)), 0);
|
||||
},
|
||||
grandTotal() {
|
||||
return Math.max(0, this.subtotal - parseFloat(this.discount || 0));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
calculateTotal() {
|
||||
// Computed properties handle this, but we can trigger other logic here if needed
|
||||
},
|
||||
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);
|
||||
},
|
||||
saveSale() {
|
||||
this.isProcessing = true;
|
||||
|
||||
let actualPaidAmount = 0;
|
||||
if (this.paymentType === 'cash') {
|
||||
actualPaidAmount = this.grandTotal;
|
||||
} else if (this.paymentType === 'partial') {
|
||||
actualPaidAmount = this.paidAmount;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
customer_id: this.customerId,
|
||||
invoice_number: this.invoiceNumber,
|
||||
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,
|
||||
paid_amount: actualPaidAmount,
|
||||
payment_type: this.paymentType,
|
||||
payment_method_id: this.paymentMethodId,
|
||||
due_date: this.dueDate,
|
||||
notes: this.notes
|
||||
};
|
||||
|
||||
fetch("{% url 'update_sale_api' sale.id %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = "{% url 'invoice_detail' sale.id %}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert("An unexpected error occurred.");
|
||||
this.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}).mount('#saleApp');
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -26,6 +26,46 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card border-0 shadow-sm rounded-4 mb-4">
|
||||
<div class="card-body p-3">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% trans "Start Date" %}</label>
|
||||
<input type="date" name="start_date" class="form-control rounded-3" value="{{ request.GET.start_date }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% trans "End Date" %}</label>
|
||||
<input type="date" name="end_date" class="form-control rounded-3" value="{{ request.GET.end_date }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">{% trans "Customer" %}</label>
|
||||
<select name="customer" class="form-select rounded-3">
|
||||
<option value="">{% trans "All Customers" %}</option>
|
||||
{% for customer in customers %}
|
||||
<option value="{{ customer.id }}" {% if request.GET.customer == customer.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ customer.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% trans "Status" %}</label>
|
||||
<select name="status" class="form-select rounded-3">
|
||||
<option value="">{% trans "All Statuses" %}</option>
|
||||
<option value="paid" {% if request.GET.status == 'paid' %}selected{% endif %}>{% trans "Paid" %}</option>
|
||||
<option value="partial" {% if request.GET.status == 'partial' %}selected{% endif %}>{% trans "Partial" %}</option>
|
||||
<option value="unpaid" {% if request.GET.status == 'unpaid' %}selected{% endif %}>{% trans "Unpaid" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-light w-100 me-2 rounded-3 shadow-sm">{% trans "Filter" %}</button>
|
||||
<a href="{% url 'invoices' %}" class="btn btn-outline-secondary w-100 rounded-3">{% trans "Reset" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
@ -69,6 +109,9 @@
|
||||
<a href="{% url 'invoice_detail' sale.id %}" class="btn btn-sm btn-white border" title="{% trans 'View & Print' %}">
|
||||
<i class="bi bi-printer"></i>
|
||||
</a>
|
||||
<a href="{% url 'edit_invoice' sale.id %}" class="btn btn-sm btn-white border" title="{% trans 'Edit' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
{% if sale.balance_due > 0 %}
|
||||
<button type="button" class="btn btn-sm btn-white border" data-bs-toggle="modal" data-bs-target="#paymentModal{{ sale.id }}" title="{% trans 'Record Payment' %}">
|
||||
<i class="bi bi-cash-stack"></i>
|
||||
@ -150,6 +193,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=sales %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
47
core/templates/core/pagination.html
Normal file
47
core/templates/core/pagination.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% if page_obj.has_other_pages %}
|
||||
<nav aria-label="Page navigation" class="mt-4">
|
||||
<ul class="pagination justify-content-center shadow-sm">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page=1" title="First Page">
|
||||
<i class="bi bi-chevron-double-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.previous_page_number }}" title="Previous Page">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-double-left"></i></span></li>
|
||||
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-left"></i></span></li>
|
||||
{% endif %}
|
||||
|
||||
{% for num in page_obj.paginator.page_range %}
|
||||
{% if page_obj.number == num %}
|
||||
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
|
||||
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ num }}">{{ num }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.next_page_number }}" title="Next Page">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?{% for key, value in request.GET.items %}{% if key != 'page' %}{{ key }}={{ value }}&{% endif %}{% endfor %}page={{ page_obj.paginator.num_pages }}" title="Last Page">
|
||||
<i class="bi bi-chevron-double-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-right"></i></span></li>
|
||||
<li class="page-item disabled"><span class="page-link"><i class="bi bi-chevron-double-right"></i></span></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
@ -95,6 +95,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=returns %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -150,6 +150,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=purchases %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -135,6 +135,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=quotations %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -95,6 +95,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=returns %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -64,6 +64,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=suppliers %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -103,6 +103,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=users %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,6 +20,7 @@ urlpatterns = [
|
||||
path('invoices/<int:pk>/', views.invoice_detail, name='invoice_detail'),
|
||||
path('invoices/payment/<int:pk>/', views.add_sale_payment, name='add_sale_payment'),
|
||||
path('invoices/delete/<int:pk>/', views.delete_sale, name='delete_sale'),
|
||||
path("invoices/edit/<int:pk>/", views.edit_invoice, name="edit_invoice"),
|
||||
|
||||
# Quotations
|
||||
path('quotations/', views.quotations, name='quotations'),
|
||||
@ -49,8 +50,16 @@ urlpatterns = [
|
||||
path('purchases/returns/delete/<int:pk>/', views.delete_purchase_return, name='delete_purchase_return'),
|
||||
path('api/create-purchase-return/', views.create_purchase_return_api, name='create_purchase_return_api'),
|
||||
|
||||
# Expenses
|
||||
path('expenses/', views.expenses_view, name='expenses'),
|
||||
path('expenses/create/', views.expense_create_view, name='expense_create'),
|
||||
path('expenses/delete/<int:pk>/', views.expense_delete_view, name='expense_delete'),
|
||||
path('expenses/categories/', views.expense_categories_view, name='expense_categories'),
|
||||
path('expenses/categories/delete/<int:pk>/', views.expense_category_delete_view, name='expense_category_delete'),
|
||||
|
||||
# API / Actions
|
||||
path('api/create-sale/', views.create_sale_api, name='create_sale_api'),
|
||||
path('api/update-sale/<int:pk>/', views.update_sale_api, name='update_sale_api'),
|
||||
path('api/create-purchase/', views.create_purchase_api, name='create_purchase_api'),
|
||||
|
||||
# POS Held Sales
|
||||
|
||||
364
core/views.py
364
core/views.py
@ -1,3 +1,4 @@
|
||||
from django.core.paginator import Paginator
|
||||
import decimal
|
||||
from django.contrib.auth.models import User, Group, Permission
|
||||
from django.urls import reverse
|
||||
@ -9,7 +10,7 @@ from django.db.models.functions import TruncDate, TruncMonth
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from .models import (
|
||||
from .models import ( Expense, ExpenseCategory,
|
||||
Product, Sale, Category, Unit, Customer, Supplier,
|
||||
Purchase, PurchaseItem, PurchasePayment,
|
||||
SaleItem, SalePayment, SystemSetting,
|
||||
@ -73,7 +74,10 @@ def index(request):
|
||||
|
||||
@login_required
|
||||
def inventory(request):
|
||||
products = Product.objects.all().select_related('category', 'unit', 'supplier')
|
||||
products_list = Product.objects.all().select_related('category', 'unit', 'supplier').order_by('-created_at')
|
||||
paginator = Paginator(products_list, 25)
|
||||
page_number = request.GET.get('page')
|
||||
products = paginator.get_page(page_number)
|
||||
categories = Category.objects.all()
|
||||
suppliers = Supplier.objects.all()
|
||||
units = Unit.objects.all()
|
||||
@ -109,13 +113,19 @@ def pos(request):
|
||||
|
||||
@login_required
|
||||
def customers(request):
|
||||
customers_list = Customer.objects.all().annotate(total_sales=Sum('sales__total_amount'))
|
||||
customers_qs = Customer.objects.all().annotate(total_sales=Sum('sales__total_amount')).order_by('name')
|
||||
paginator = Paginator(customers_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
customers_list = paginator.get_page(page_number)
|
||||
context = {'customers': customers_list}
|
||||
return render(request, 'core/customers.html', context)
|
||||
|
||||
@login_required
|
||||
def suppliers(request):
|
||||
suppliers_list = Supplier.objects.all()
|
||||
suppliers_qs = Supplier.objects.all().order_by('name')
|
||||
paginator = Paginator(suppliers_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
suppliers_list = paginator.get_page(page_number)
|
||||
context = {'suppliers': suppliers_list}
|
||||
return render(request, 'core/suppliers.html', context)
|
||||
|
||||
@ -123,8 +133,14 @@ def suppliers(request):
|
||||
|
||||
@login_required
|
||||
def purchases(request):
|
||||
purchases_list = Purchase.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
suppliers_list = Supplier.objects.all()
|
||||
purchases_qs = Purchase.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(purchases_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
purchases_list = paginator.get_page(page_number)
|
||||
suppliers_qs = Supplier.objects.all().order_by('name')
|
||||
paginator = Paginator(suppliers_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
suppliers_list = paginator.get_page(page_number)
|
||||
payment_methods = PaymentMethod.objects.filter(is_active=True)
|
||||
context = {
|
||||
'purchases': purchases_list,
|
||||
@ -266,17 +282,38 @@ def delete_purchase(request, pk):
|
||||
|
||||
@login_required
|
||||
def invoice_list(request):
|
||||
sales = Sale.objects.all().select_related('customer', 'created_by').order_by('-created_at')
|
||||
sales = Sale.objects.all().select_related("customer", "created_by")
|
||||
|
||||
# Filtering
|
||||
start_date = request.GET.get("start_date")
|
||||
end_date = request.GET.get("end_date")
|
||||
customer_id = request.GET.get("customer")
|
||||
status = request.GET.get("status")
|
||||
|
||||
if start_date:
|
||||
sales = sales.filter(created_at__date__gte=start_date)
|
||||
if end_date:
|
||||
sales = sales.filter(created_at__date__lte=end_date)
|
||||
if customer_id:
|
||||
sales = sales.filter(customer_id=customer_id)
|
||||
if status:
|
||||
sales = sales.filter(status=status)
|
||||
|
||||
sales = sales.order_by("-created_at")
|
||||
paginator = Paginator(sales, 25)
|
||||
page_number = request.GET.get("page")
|
||||
sales = paginator.get_page(page_number)
|
||||
|
||||
customers = Customer.objects.all()
|
||||
payment_methods = PaymentMethod.objects.filter(is_active=True)
|
||||
return render(request, 'core/invoices.html', {
|
||||
'sales': sales,
|
||||
'customers': customers,
|
||||
'payment_methods': payment_methods
|
||||
return render(request, "core/invoices.html", {
|
||||
"sales": sales,
|
||||
"customers": customers,
|
||||
"payment_methods": payment_methods
|
||||
})
|
||||
|
||||
@login_required
|
||||
def invoice_create(request):
|
||||
|
||||
products = Product.objects.filter(is_active=True)
|
||||
customers = Customer.objects.all()
|
||||
payment_methods = PaymentMethod.objects.filter(is_active=True)
|
||||
@ -292,8 +329,37 @@ def invoice_detail(request, pk):
|
||||
settings = SystemSetting.objects.first()
|
||||
return render(request, 'core/invoice_detail.html', {'sale': sale, 'settings': settings})
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def edit_invoice(request, pk):
|
||||
sale = get_object_or_404(Sale, pk=pk)
|
||||
# Prepare cart items for JSON
|
||||
cart_items = []
|
||||
for item in sale.items.all():
|
||||
cart_items.append({
|
||||
'id': item.product.id,
|
||||
'name_en': item.product.name_en,
|
||||
'sku': item.product.sku,
|
||||
'price': float(item.unit_price),
|
||||
'quantity': item.quantity
|
||||
})
|
||||
|
||||
customers = Customer.objects.all()
|
||||
products = Product.objects.filter(is_active=True)
|
||||
payment_methods = PaymentMethod.objects.filter(is_active=True)
|
||||
|
||||
# Find initial payment method
|
||||
initial_payment = sale.payments.filter(notes='Initial payment').first()
|
||||
payment_method_id = initial_payment.payment_method_id if initial_payment else ''
|
||||
|
||||
return render(request, 'core/invoice_edit.html', {
|
||||
'sale': sale,
|
||||
'customers': customers,
|
||||
'products': products,
|
||||
'payment_methods': payment_methods,
|
||||
'cart_json': json.dumps(cart_items),
|
||||
'payment_method_id': payment_method_id
|
||||
})
|
||||
|
||||
def create_sale_api(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
@ -482,7 +548,10 @@ def delete_sale(request, pk):
|
||||
|
||||
@login_required
|
||||
def quotations(request):
|
||||
quotations_list = Quotation.objects.all().select_related('customer', 'created_by').order_by('-created_at')
|
||||
quotations_qs = Quotation.objects.all().select_related('customer', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(quotations_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
quotations_list = paginator.get_page(page_number)
|
||||
customers = Customer.objects.all()
|
||||
return render(request, 'core/quotations.html', {'quotations': quotations_list, 'customers': customers})
|
||||
|
||||
@ -594,7 +663,10 @@ def delete_quotation(request, pk):
|
||||
|
||||
@login_required
|
||||
def sales_returns(request):
|
||||
returns = SaleReturn.objects.all().select_related('customer', 'created_by').order_by('-created_at')
|
||||
returns_qs = SaleReturn.objects.all().select_related('customer', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(returns_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
returns = paginator.get_page(page_number)
|
||||
return render(request, 'core/sales_returns.html', {'returns': returns})
|
||||
|
||||
@login_required
|
||||
@ -677,7 +749,10 @@ def delete_sale_return(request, pk):
|
||||
|
||||
@login_required
|
||||
def purchase_returns(request):
|
||||
returns = PurchaseReturn.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
returns_qs = PurchaseReturn.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(returns_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
returns = paginator.get_page(page_number)
|
||||
return render(request, 'core/purchase_returns.html', {'returns': returns})
|
||||
|
||||
@login_required
|
||||
@ -1221,7 +1296,10 @@ def user_management(request):
|
||||
messages.error(request, "Access denied.")
|
||||
return redirect('index')
|
||||
|
||||
users = User.objects.all().prefetch_related('groups')
|
||||
users_qs = User.objects.all().prefetch_related('groups').order_by('username')
|
||||
paginator = Paginator(users_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
users = paginator.get_page(page_number)
|
||||
groups = Group.objects.all().prefetch_related('permissions')
|
||||
# Filter for relevant permissions (core and auth)
|
||||
permissions = Permission.objects.select_related('content_type').all().order_by('content_type__app_label', 'codename')
|
||||
@ -1529,3 +1607,255 @@ def profile_view(request):
|
||||
return redirect('profile')
|
||||
|
||||
return render(request, 'core/profile.html')
|
||||
|
||||
# --- Expenses Views ---
|
||||
|
||||
@login_required
|
||||
def expenses_view(request):
|
||||
"""
|
||||
List and filter expenses
|
||||
"""
|
||||
expenses = Expense.objects.all().order_by('-date', '-created_at')
|
||||
|
||||
# Filtering
|
||||
start_date = request.GET.get('start_date')
|
||||
end_date = request.GET.get('end_date')
|
||||
category_id = request.GET.get('category')
|
||||
|
||||
if start_date:
|
||||
expenses = expenses.filter(date__gte=start_date)
|
||||
if end_date:
|
||||
expenses = expenses.filter(date__lte=end_date)
|
||||
if category_id:
|
||||
expenses = expenses.filter(category_id=category_id)
|
||||
|
||||
paginator = Paginator(expenses, 25)
|
||||
page_number = request.GET.get('page')
|
||||
expenses = paginator.get_page(page_number)
|
||||
categories = ExpenseCategory.objects.all()
|
||||
payment_methods = PaymentMethod.objects.filter(is_active=True)
|
||||
|
||||
context = {
|
||||
'expenses': expenses,
|
||||
'categories': categories,
|
||||
'payment_methods': payment_methods,
|
||||
'start_date': start_date,
|
||||
'end_date': end_date,
|
||||
'category_id': category_id,
|
||||
}
|
||||
return render(request, 'core/expenses.html', context)
|
||||
|
||||
@login_required
|
||||
def expense_create_view(request):
|
||||
"""
|
||||
Create a new expense
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
category_id = request.POST.get('category')
|
||||
amount = request.POST.get('amount')
|
||||
date = request.POST.get('date') or timezone.now().date()
|
||||
description = request.POST.get('description', '')
|
||||
payment_method_id = request.POST.get('payment_method')
|
||||
attachment = request.FILES.get('attachment')
|
||||
|
||||
category = get_object_or_404(ExpenseCategory, id=category_id)
|
||||
pm = None
|
||||
if payment_method_id:
|
||||
pm = get_object_or_404(PaymentMethod, id=payment_method_id)
|
||||
|
||||
Expense.objects.create(
|
||||
category=category,
|
||||
amount=amount,
|
||||
date=date,
|
||||
description=description,
|
||||
payment_method=pm,
|
||||
attachment=attachment,
|
||||
created_by=request.user
|
||||
)
|
||||
messages.success(request, "Expense recorded successfully!")
|
||||
|
||||
return redirect('expenses')
|
||||
|
||||
@login_required
|
||||
def expense_delete_view(request, pk):
|
||||
"""
|
||||
Delete an expense
|
||||
"""
|
||||
expense = get_object_or_404(Expense, pk=pk)
|
||||
expense.delete()
|
||||
messages.success(request, "Expense deleted successfully!")
|
||||
return redirect('expenses')
|
||||
|
||||
@login_required
|
||||
def expense_categories_view(request):
|
||||
"""
|
||||
Manage expense categories
|
||||
"""
|
||||
if request.method == 'POST':
|
||||
name_en = request.POST.get('name_en')
|
||||
name_ar = request.POST.get('name_ar')
|
||||
description = request.POST.get('description', '')
|
||||
|
||||
ExpenseCategory.objects.create(
|
||||
name_en=name_en,
|
||||
name_ar=name_ar,
|
||||
description=description
|
||||
)
|
||||
messages.success(request, "Expense category created successfully!")
|
||||
return redirect('expense_categories')
|
||||
|
||||
paginator = Paginator(expenses, 25)
|
||||
page_number = request.GET.get('page')
|
||||
expenses = paginator.get_page(page_number)
|
||||
categories = ExpenseCategory.objects.all()
|
||||
return render(request, 'core/expense_categories.html', {'categories': categories})
|
||||
|
||||
@login_required
|
||||
def expense_category_delete_view(request, pk):
|
||||
"""
|
||||
Delete an expense category
|
||||
"""
|
||||
category = get_object_or_404(ExpenseCategory, pk=pk)
|
||||
category.delete()
|
||||
messages.success(request, "Expense category deleted successfully!")
|
||||
return redirect('expense_categories')
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def update_sale_api(request, pk):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
sale = get_object_or_404(Sale, pk=pk)
|
||||
data = json.loads(request.body)
|
||||
customer_id = data.get('customer_id')
|
||||
invoice_number = data.get('invoice_number', '')
|
||||
items = data.get('items', [])
|
||||
total_amount = data.get('total_amount', 0)
|
||||
paid_amount = data.get('paid_amount', 0)
|
||||
discount = data.get('discount', 0)
|
||||
payment_type = data.get('payment_type', 'cash')
|
||||
payment_method_id = data.get('payment_method_id')
|
||||
due_date = data.get('due_date')
|
||||
notes = data.get('notes', '')
|
||||
points_to_redeem = data.get('loyalty_points_redeemed', 0)
|
||||
|
||||
settings = SystemSetting.objects.first()
|
||||
if not settings:
|
||||
settings = SystemSetting.objects.create()
|
||||
|
||||
# 1. Restore Stock
|
||||
for item in sale.items.all():
|
||||
item.product.stock_quantity += item.quantity
|
||||
item.product.save()
|
||||
|
||||
# 2. Reverse Loyalty Points for the old customer
|
||||
if sale.customer and settings.loyalty_enabled:
|
||||
for lt in sale.loyalty_transactions.all():
|
||||
sale.customer.loyalty_points -= decimal.Decimal(str(lt.points))
|
||||
lt.delete()
|
||||
sale.customer.update_tier()
|
||||
sale.customer.save()
|
||||
|
||||
# 3. Update Sale Metadata
|
||||
customer = None
|
||||
if customer_id:
|
||||
customer = Customer.objects.get(id=customer_id)
|
||||
|
||||
sale.customer = customer
|
||||
sale.invoice_number = invoice_number
|
||||
sale.total_amount = total_amount
|
||||
sale.discount = discount
|
||||
sale.payment_type = payment_type
|
||||
sale.due_date = due_date if due_date else None
|
||||
sale.notes = notes
|
||||
|
||||
# Loyalty discount recalculation
|
||||
loyalty_discount = 0
|
||||
if settings.loyalty_enabled and customer and points_to_redeem > 0:
|
||||
if float(customer.loyalty_points) >= float(points_to_redeem):
|
||||
loyalty_discount = float(points_to_redeem) * float(settings.currency_per_point)
|
||||
|
||||
sale.loyalty_points_redeemed = points_to_redeem
|
||||
sale.loyalty_discount_amount = loyalty_discount
|
||||
sale.save()
|
||||
|
||||
# 4. Handle Items (Delete old, Create new)
|
||||
sale.items.all().delete()
|
||||
for item in items:
|
||||
product = Product.objects.get(id=item['id'])
|
||||
SaleItem.objects.create(
|
||||
sale=sale,
|
||||
product=product,
|
||||
quantity=item['quantity'],
|
||||
unit_price=item['price'],
|
||||
line_total=item['line_total']
|
||||
)
|
||||
product.stock_quantity -= int(item['quantity'])
|
||||
product.save()
|
||||
|
||||
# 5. Handle Payments
|
||||
sale.paid_amount = paid_amount
|
||||
sale.balance_due = float(total_amount) - float(paid_amount)
|
||||
|
||||
if float(paid_amount) >= float(total_amount):
|
||||
sale.status = 'paid'
|
||||
elif float(paid_amount) > 0:
|
||||
sale.status = 'partial'
|
||||
else:
|
||||
sale.status = 'unpaid'
|
||||
sale.save()
|
||||
|
||||
pm = None
|
||||
if payment_method_id:
|
||||
pm = PaymentMethod.objects.filter(id=payment_method_id).first()
|
||||
|
||||
initial_payment = sale.payments.filter(notes="Initial payment").first()
|
||||
if initial_payment:
|
||||
initial_payment.amount = paid_amount
|
||||
initial_payment.payment_method = pm
|
||||
initial_payment.payment_method_name = pm.name_en if pm else payment_type.capitalize()
|
||||
initial_payment.save()
|
||||
elif float(paid_amount) > 0:
|
||||
SalePayment.objects.create(
|
||||
sale=sale,
|
||||
amount=paid_amount,
|
||||
payment_method=pm,
|
||||
payment_method_name=pm.name_en if pm else payment_type.capitalize(),
|
||||
notes="Initial payment",
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
# 6. Re-apply Loyalty for the (possibly new) customer
|
||||
if settings.loyalty_enabled and customer:
|
||||
points_earned = float(total_amount) * float(settings.points_per_currency)
|
||||
if customer.loyalty_tier:
|
||||
points_earned *= float(customer.loyalty_tier.point_multiplier)
|
||||
|
||||
if points_earned > 0:
|
||||
customer.loyalty_points += decimal.Decimal(str(points_earned))
|
||||
LoyaltyTransaction.objects.create(
|
||||
customer=customer,
|
||||
sale=sale,
|
||||
transaction_type='earned',
|
||||
points=points_earned,
|
||||
notes=f"Points earned from Updated Sale #{sale.id}"
|
||||
)
|
||||
|
||||
if points_to_redeem > 0:
|
||||
customer.loyalty_points -= decimal.Decimal(str(points_to_redeem))
|
||||
LoyaltyTransaction.objects.create(
|
||||
customer=customer,
|
||||
sale=sale,
|
||||
transaction_type='redeemed',
|
||||
points=-points_to_redeem,
|
||||
notes=f"Points redeemed for Updated Sale #{sale.id}"
|
||||
)
|
||||
|
||||
customer.update_tier()
|
||||
customer.save()
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
return JsonResponse({'success': False, 'error': 'Invalid request'}, status=405)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user