Autosave: 20260206-031750
This commit is contained in:
parent
850f4f6187
commit
c2c1cb5d82
Binary file not shown.
Binary file not shown.
@ -61,7 +61,7 @@ INSTALLED_APPS = [
|
||||
'django.contrib.staticfiles',
|
||||
'core',
|
||||
'accounting',
|
||||
'hr',
|
||||
# 'hr',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@ -9,10 +9,10 @@ urlpatterns = [
|
||||
path("i18n/", include("django.conf.urls.i18n")),
|
||||
path("", include("core.urls")),
|
||||
path("accounting/", include("accounting.urls")),
|
||||
path("hr/", include("hr.urls")),
|
||||
# path("hr/", include("hr.urls")),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
124
core/models.py
124
core/models.py
@ -255,6 +255,9 @@ class Purchase(models.Model):
|
||||
notes = models.TextField(_("Notes"), blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="purchases")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# New field to link back to LOP
|
||||
lop = models.OneToOneField('LOP', on_delete=models.SET_NULL, null=True, blank=True, related_name="purchase")
|
||||
|
||||
def __str__(self):
|
||||
return f"Purchase #{self.id} - {self.supplier.name if self.supplier else 'N/A'}"
|
||||
@ -304,7 +307,7 @@ class SaleReturn(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Sale Return #{self.id} - {self.customer.name if self.customer else 'Guest'}"
|
||||
return f"Return #{self.id} for Sale #{self.sale.id if self.sale else 'N/A'}"
|
||||
|
||||
class SaleReturnItem(models.Model):
|
||||
sale_return = models.ForeignKey(SaleReturn, on_delete=models.CASCADE, related_name="items")
|
||||
@ -326,72 +329,74 @@ class PurchaseReturn(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Purchase Return #{self.id} - {self.supplier.name if self.supplier else 'N/A'}"
|
||||
return f"Return #{self.id} for Purchase #{self.purchase.id if self.purchase else 'N/A'}"
|
||||
|
||||
class PurchaseReturnItem(models.Model):
|
||||
purchase_return = models.ForeignKey(PurchaseReturn, on_delete=models.CASCADE, related_name="items")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||
quantity = models.DecimalField(_("Quantity"), max_digits=15, decimal_places=2)
|
||||
cost_price = models.DecimalField(_("Cost Price"), max_digits=12, decimal_places=3)
|
||||
expiry_date = models.DateField(_("Expiry Date"), null=True, blank=True)
|
||||
line_total = models.DecimalField(_("Line Total"), max_digits=15, decimal_places=3)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name_en} x {self.quantity}"
|
||||
|
||||
class SystemSetting(models.Model):
|
||||
business_name = models.CharField(_("Business Name"), max_length=200, default="Meezan Accounting")
|
||||
address = models.TextField(_("Address"), blank=True)
|
||||
phone = models.CharField(_("Phone"), max_length=50, blank=True)
|
||||
email = models.EmailField(_("Email"), blank=True)
|
||||
vat_number = models.CharField(_("VAT Number"), max_length=50, blank=True)
|
||||
logo = models.ImageField(_("Company Logo"), upload_to="company_logos/", blank=True, null=True)
|
||||
currency = models.CharField(_("Currency"), max_length=10, default="SAR")
|
||||
vat_percentage = models.DecimalField(_("Default VAT %"), max_digits=5, decimal_places=2, default=15.00)
|
||||
footer_text = models.TextField(_("Invoice Footer Text"), blank=True)
|
||||
decimal_places = models.IntegerField(_("Decimal Places"), default=2)
|
||||
|
||||
# Wablas (WhatsApp) Integration
|
||||
wablas_enabled = models.BooleanField(_("Enable Wablas WhatsApp"), default=False)
|
||||
wablas_server_url = models.URLField(_("Wablas Server URL"), blank=True, help_text=_("e.g., https://tegal.wablas.com"))
|
||||
wablas_api_token = models.CharField(_("Wablas API Token"), max_length=255, blank=True)
|
||||
wablas_secret_key = models.CharField(_("Wablas Secret Key"), max_length=255, blank=True, help_text=_("Used for verifying webhooks"))
|
||||
|
||||
def __str__(self):
|
||||
return "System Settings"
|
||||
|
||||
class HeldSale(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="held_sales", null=True, blank=True) # Changed to match migration
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="held_sales_created") # Added to match migration potentially
|
||||
customer = models.ForeignKey(Customer, on_delete=models.SET_NULL, null=True, blank=True, related_name="held_sales")
|
||||
cart_data = models.JSONField(_("Cart Data"))
|
||||
total_amount = models.DecimalField(_("Total Amount"), max_digits=15, decimal_places=3)
|
||||
notes = models.TextField(_("Notes"), blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="held_sales")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Held Sale #{self.id} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
||||
return f"Held Sale {self.id} - {self.customer.name if self.customer else 'Guest'}"
|
||||
|
||||
class SystemSetting(models.Model):
|
||||
business_name = models.CharField(_("Business Name"), max_length=200, default="Meezan Accounting")
|
||||
address = models.TextField(_("Address"), blank=True)
|
||||
phone = models.CharField(_("Phone"), max_length=20, blank=True)
|
||||
email = models.EmailField(_("Email"), blank=True)
|
||||
currency_symbol = models.CharField(_("Currency Symbol"), max_length=10, default="OMR")
|
||||
tax_rate = models.DecimalField(_("Tax Rate (%)"), max_digits=5, decimal_places=2, default=0)
|
||||
decimal_places = models.PositiveSmallIntegerField(_("Decimal Places"), default=3)
|
||||
logo = models.ImageField(_("Logo"), upload_to="business_logos/", blank=True, null=True)
|
||||
vat_number = models.CharField(_("VAT Number"), max_length=50, blank=True)
|
||||
registration_number = models.CharField(_("Registration Number"), max_length=50, blank=True)
|
||||
|
||||
# Loyalty Settings
|
||||
loyalty_enabled = models.BooleanField(_("Enable Loyalty System"), default=False)
|
||||
points_per_currency = models.DecimalField(_("Points Earned per Currency Unit"), max_digits=10, decimal_places=2, default=1.0)
|
||||
currency_per_point = models.DecimalField(_("Currency Value per Point"), max_digits=10, decimal_places=3, default=0.010)
|
||||
min_points_to_redeem = models.PositiveIntegerField(_("Minimum Points to Redeem"), default=100)
|
||||
|
||||
# WhatsApp (Wablas) Settings
|
||||
wablas_enabled = models.BooleanField(_("Enable WhatsApp Gateway"), default=False)
|
||||
wablas_token = models.CharField(_("Wablas API Token"), max_length=255, blank=True)
|
||||
wablas_server_url = models.URLField(_("Wablas Server URL"), blank=True, help_text="Example: https://console.wablas.com")
|
||||
wablas_secret_key = models.CharField(_("Wablas Secret Key"), max_length=255, blank=True)
|
||||
class UserProfile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
|
||||
image = models.ImageField(_("Profile Picture"), upload_to='profile_pics/', null=True, blank=True)
|
||||
phone = models.CharField(_("Phone Number"), max_length=20, blank=True)
|
||||
bio = models.TextField(_("Bio"), blank=True)
|
||||
role = models.CharField(max_length=20, choices=[('admin', 'Admin'), ('manager', 'Manager'), ('cashier', 'Cashier')], default='cashier')
|
||||
|
||||
def __str__(self):
|
||||
return self.business_name
|
||||
|
||||
return self.user.username
|
||||
|
||||
class Device(models.Model):
|
||||
DEVICE_TYPES = [
|
||||
('printer', _('Printer')),
|
||||
('scanner', _('Scanner')),
|
||||
('scale', _('Weight Scale')),
|
||||
('display', _('Customer Display')),
|
||||
('other', _('Other')),
|
||||
('printer', 'Printer'),
|
||||
('scanner', 'Scanner'),
|
||||
('scale', 'Weight Scale'),
|
||||
('display', 'Customer Display'),
|
||||
('other', 'Other'),
|
||||
]
|
||||
CONNECTION_TYPES = [
|
||||
('network', _('Network (IP)')),
|
||||
('usb', _('USB')),
|
||||
('bluetooth', _('Bluetooth')),
|
||||
('network', 'Network (IP)'),
|
||||
('usb', 'USB'),
|
||||
('bluetooth', 'Bluetooth'),
|
||||
]
|
||||
|
||||
name = models.CharField(_("Device Name"), max_length=100)
|
||||
device_type = models.CharField(_("Device Type"), max_length=20, choices=DEVICE_TYPES)
|
||||
connection_type = models.CharField(_("Connection Type"), max_length=20, choices=CONNECTION_TYPES, default='network')
|
||||
@ -402,24 +407,33 @@ class Device(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_device_type_display()})"
|
||||
return self.name
|
||||
|
||||
class UserProfile(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
image = models.ImageField(_("Profile Picture"), upload_to="profile_pics/", blank=True, null=True)
|
||||
phone = models.CharField(_("Phone Number"), max_length=20, blank=True)
|
||||
bio = models.TextField(_("Bio"), blank=True)
|
||||
class LOP(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('draft', _('Draft')),
|
||||
('converted', _('Converted to Purchase')),
|
||||
('cancelled', _('Cancelled')),
|
||||
]
|
||||
|
||||
supplier = models.ForeignKey(Supplier, on_delete=models.SET_NULL, null=True, related_name="lops")
|
||||
lop_number = models.CharField(_("LOP Number"), max_length=50, blank=True)
|
||||
total_amount = models.DecimalField(_("Total Amount"), max_digits=15, decimal_places=3)
|
||||
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='draft')
|
||||
date = models.DateField(_("Date"), default=timezone.now)
|
||||
notes = models.TextField(_("Notes"), blank=True)
|
||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="lops")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.user.username
|
||||
return f"LOP #{self.id} - {self.supplier.name if self.supplier else 'N/A'}"
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserProfile.objects.create(user=instance)
|
||||
class LOPItem(models.Model):
|
||||
lop = models.ForeignKey(LOP, on_delete=models.CASCADE, related_name="items")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE)
|
||||
quantity = models.DecimalField(_("Quantity"), max_digits=15, decimal_places=2)
|
||||
cost_price = models.DecimalField(_("Cost Price"), max_digits=12, decimal_places=3)
|
||||
line_total = models.DecimalField(_("Line Total"), max_digits=15, decimal_places=3)
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_profile(sender, instance, **kwargs):
|
||||
if not hasattr(instance, 'profile'):
|
||||
UserProfile.objects.create(user=instance)
|
||||
instance.profile.save()
|
||||
def __str__(self):
|
||||
return f"{self.product.name_en} x {self.quantity}"
|
||||
@ -88,11 +88,16 @@
|
||||
|
||||
<!-- Purchases Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#purchasesSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'purchases' or url_name == 'purchase_create' or url_name == 'purchase_detail' or url_name == 'supplier_payments' or 'purchases/returns' in path %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<a href="#purchasesSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'purchases' or url_name == 'purchase_create' or url_name == 'purchase_detail' or url_name == 'supplier_payments' or 'purchases/returns' in path or url_name == 'lops' or url_name == 'lop_create' or url_name == 'lop_detail' %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<span>{% trans "Purchases" %}</span>
|
||||
<i class="bi bi-chevron-down chevron"></i>
|
||||
</a>
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'purchases' or url_name == 'purchase_create' or url_name == 'purchase_detail' or url_name == 'supplier_payments' or 'purchases/returns' in path %}show{% endif %}" id="purchasesSubmenu">
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'purchases' or url_name == 'purchase_create' or url_name == 'purchase_detail' or url_name == 'supplier_payments' or 'purchases/returns' in path or url_name == 'lops' or url_name == 'lop_create' or url_name == 'lop_detail' %}show{% endif %}" id="purchasesSubmenu">
|
||||
<li>
|
||||
<a href="{% url 'lops' %}" class="{% if url_name == 'lops' or url_name == 'lop_create' or url_name == 'lop_detail' %}active{% endif %}">
|
||||
<i class="bi bi-file-earmark-text"></i> {% trans "Local Purchase Orders (LOP)" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'purchase_create' %}" class="{% if url_name == 'purchase_create' %}active{% endif %}">
|
||||
<i class="bi bi-plus-circle"></i> {% trans "New Purchase" %}
|
||||
@ -219,46 +224,6 @@
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- HR Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#hrSubmenu" data-bs-toggle="collapse" aria-expanded="{% if 'hr/' in path %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<span>{% trans "Human Resources" %}</span>
|
||||
<i class="bi bi-chevron-down chevron"></i>
|
||||
</a>
|
||||
<ul class="collapse list-unstyled sub-menu {% if 'hr/' in path %}show{% endif %}" id="hrSubmenu">
|
||||
<li>
|
||||
<a href="{% url 'hr:dashboard' %}" class="{% if url_name == 'dashboard' and 'hr/' in path %}active{% endif %}">
|
||||
<i class="bi bi-speedometer"></i> {% trans "Overview" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'hr:employee_list' %}" class="{% if 'hr/employees' in path %}active{% endif %}">
|
||||
<i class="bi bi-people-fill"></i> {% trans "Employees" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'hr:department_list' %}" class="{% if 'hr/departments' in path %}active{% endif %}">
|
||||
<i class="bi bi-diagram-3"></i> {% trans "Departments" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'hr:attendance_list' %}" class="{% if 'hr/attendance' in path %}active{% endif %}">
|
||||
<i class="bi bi-clock-history"></i> {% trans "Attendance" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'hr:leave_list' %}" class="{% if 'hr/leave' in path %}active{% endif %}">
|
||||
<i class="bi bi-calendar2-range"></i> {% trans "Leave Requests" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'hr:device_list' %}" class="{% if 'hr/devices' in path %}active{% endif %}">
|
||||
<i class="bi bi-fingerprint"></i> {% trans "Biometric Devices" %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<!-- Reports Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#reportsSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'reports' or url_name == 'customer_statement' or url_name == 'supplier_statement' or url_name == 'cashflow_report' %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
|
||||
241
core/templates/core/lop_create.html
Normal file
241
core/templates/core/lop_create.html
Normal file
@ -0,0 +1,241 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n l10n %}
|
||||
|
||||
{% block title %}{% trans "New LOP" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4" id="lopApp">
|
||||
<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-file-earmark-text me-2 text-primary"></i>{% trans "Create Local Purchase Order" %}</h5>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<!-- Supplier & Invoice Info -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "Supplier" %}</label>
|
||||
<select class="form-select rounded-3 shadow-none border-secondary-subtle" v-model="supplierId">
|
||||
<option value="">{% trans "Select Supplier" %}</option>
|
||||
{% for supplier in suppliers %}
|
||||
<option value="{{ supplier.id }}">{{ supplier.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold">{% trans "LOP Number / Reference" %}</label>
|
||||
<input type="text" class="form-control rounded-3 shadow-none border-secondary-subtle" v-model="lopNumber" placeholder="{% trans 'e.g. LOP-001' %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Selection -->
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-bold">{% trans "Add Items" %}</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 ]]</div>
|
||||
</div>
|
||||
<div class="text-primary fw-bold">[[ currencySymbol ]][[ product.cost_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 "Cost 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 "No items added yet." %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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 "LOP Summary" %}</h5>
|
||||
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="text-muted">{% trans "Total Amount" %}</span>
|
||||
<span class="fw-bold">[[ currencySymbol ]][[ subtotal.toFixed(decimalPlaces) ]]</span>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<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 || !supplierId" @click="saveLOP">
|
||||
<span v-if="isProcessing" class="spinner-border spinner-border-sm me-2"></span>
|
||||
<i class="bi bi-check2-circle me-2" v-else></i>
|
||||
{% trans "Save LOP" %}
|
||||
</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 }}",
|
||||
cost_price: {{ p.cost_price|default:0 }}
|
||||
},
|
||||
{% endfor %}
|
||||
],
|
||||
searchQuery: '',
|
||||
filteredProducts: [],
|
||||
cart: [],
|
||||
supplierId: '',
|
||||
lopNumber: '',
|
||||
notes: '',
|
||||
currencySymbol: '{{ site_settings.currency_symbol|escapejs }}',
|
||||
decimalPlaces: {{ site_settings.decimal_places|default:3 }},
|
||||
isProcessing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
subtotal() {
|
||||
return this.cart.reduce((total, item) => total + (item.price * item.quantity), 0);
|
||||
}
|
||||
},
|
||||
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.cost_price,
|
||||
quantity: 1
|
||||
});
|
||||
}
|
||||
this.searchQuery = '';
|
||||
this.filteredProducts = [];
|
||||
},
|
||||
removeItem(index) {
|
||||
this.cart.splice(index, 1);
|
||||
},
|
||||
saveLOP() {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
|
||||
const payload = {
|
||||
supplier_id: this.supplierId,
|
||||
lop_number: this.lopNumber,
|
||||
items: this.cart.map(item => ({
|
||||
id: item.id,
|
||||
quantity: item.quantity,
|
||||
price: item.price,
|
||||
line_total: item.price * item.quantity
|
||||
})),
|
||||
total_amount: this.subtotal,
|
||||
notes: this.notes
|
||||
};
|
||||
|
||||
fetch("{% url 'create_lop_api' %}", {
|
||||
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 'lops' %}";
|
||||
} else {
|
||||
alert("Error: " + data.error);
|
||||
this.isProcessing = false;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert("An unexpected error occurred.");
|
||||
this.isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}).mount('#lopApp');
|
||||
</script>
|
||||
{% endlocalize %}
|
||||
{% endblock %}
|
||||
108
core/templates/core/lop_detail.html
Normal file
108
core/templates/core/lop_detail.html
Normal file
@ -0,0 +1,108 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n humanize %}
|
||||
|
||||
{% block title %}{% trans "LOP Details" %} #{{ lop.lop_number|default:lop.id }} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">{% trans "LOP" %} #{{ lop.lop_number|default:lop.id }}</h2>
|
||||
<p class="text-muted small mb-0">{{ lop.created_at|date:"F j, Y" }}</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'lops' %}" class="btn btn-light rounded-3 shadow-sm border">
|
||||
<i class="bi bi-arrow-left me-2"></i>{% trans "Back" %}
|
||||
</a>
|
||||
{% if lop.status == 'draft' %}
|
||||
<a href="{% url 'convert_lop_to_purchase' lop.id %}" class="btn btn-primary rounded-3 shadow-sm" onclick="return confirm('{% trans "Are you sure?" %}')">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>{% trans "Convert to Purchase" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button onclick="window.print()" class="btn btn-secondary rounded-3 shadow-sm">
|
||||
<i class="bi bi-printer me-2"></i>{% trans "Print" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm rounded-4">
|
||||
<div class="card-body p-5">
|
||||
<!-- Header -->
|
||||
<div class="row mb-5">
|
||||
<div class="col-6">
|
||||
<h3 class="fw-bold text-primary mb-2">{{ site_settings.business_name }}</h3>
|
||||
<p class="text-muted mb-0" style="white-space: pre-line;">{{ site_settings.address }}
|
||||
{{ site_settings.phone }}
|
||||
{{ site_settings.email }}</p>
|
||||
</div>
|
||||
<div class="col-6 text-end">
|
||||
<h1 class="fw-bold text-secondary mb-2">{% trans "LOCAL PURCHASE ORDER" %}</h1>
|
||||
<p class="mb-1"><strong>{% trans "LOP #" %}:</strong> {{ lop.lop_number|default:lop.id }}</p>
|
||||
<p class="mb-1"><strong>{% trans "Date" %}:</strong> {{ lop.created_at|date:"Y-m-d" }}</p>
|
||||
<p class="mb-0"><strong>{% trans "Status" %}:</strong>
|
||||
{% if lop.status == 'converted' %}<span class="badge bg-success">{% trans "Converted" %}</span>
|
||||
{% elif lop.status == 'cancelled' %}<span class="badge bg-danger">{% trans "Cancelled" %}</span>
|
||||
{% else %}<span class="badge bg-secondary">{% trans "Draft" %}</span>{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplier Info -->
|
||||
<div class="row mb-5">
|
||||
<div class="col-6">
|
||||
<h6 class="text-uppercase text-muted fw-bold small mb-3">{% trans "Bill To" %}</h6>
|
||||
<h5 class="fw-bold mb-1">{{ lop.supplier.name|default:"-" }}</h5>
|
||||
<p class="text-muted mb-0">{{ lop.supplier.phone }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div class="table-responsive mb-5">
|
||||
<table class="table table-borderless">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4 py-3">{% trans "Description" %}</th>
|
||||
<th class="text-center py-3">{% trans "Quantity" %}</th>
|
||||
<th class="text-end py-3">{% trans "Unit Cost" %}</th>
|
||||
<th class="text-end pe-4 py-3">{% trans "Total" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in lop.items.all %}
|
||||
<tr class="border-bottom">
|
||||
<td class="ps-4 py-3">
|
||||
<div class="fw-bold">{{ item.product.name_en }}</div>
|
||||
<div class="text-muted small">{{ item.product.name_ar }}</div>
|
||||
</td>
|
||||
<td class="text-center py-3">{{ item.quantity }}</td>
|
||||
<td class="text-end py-3">{{ site_settings.currency_symbol }}{{ item.cost_price|floatformat:3 }}</td>
|
||||
<td class="text-end pe-4 py-3 fw-bold">{{ site_settings.currency_symbol }}{{ item.line_total|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="row justify-content-end">
|
||||
<div class="col-md-5 col-lg-4">
|
||||
<div class="d-flex justify-content-between mb-3">
|
||||
<span class="fw-bold h5">{% trans "Total Amount" %}</span>
|
||||
<span class="fw-bold h5 text-primary">{{ site_settings.currency_symbol }}{{ lop.total_amount|floatformat:3 }}</span>
|
||||
</div>
|
||||
{% if amount_in_words %}
|
||||
<p class="text-muted small text-end fst-italic">{{ amount_in_words }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if lop.notes %}
|
||||
<div class="mt-5">
|
||||
<h6 class="fw-bold">{% trans "Notes" %}</h6>
|
||||
<p class="text-muted">{{ lop.notes }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
120
core/templates/core/lops.html
Normal file
120
core/templates/core/lops.html
Normal file
@ -0,0 +1,120 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Local Purchase Orders (LOP)" %} | {{ site_settings.business_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="fw-bold mb-0">{% trans "Local Purchase Orders (LOP)" %}</h2>
|
||||
<p class="text-muted small mb-0">{% trans "Manage local purchase orders and convert them to invoices" %}</p>
|
||||
</div>
|
||||
<a href="{% url 'lop_create' %}" class="btn btn-primary rounded-3 px-4 shadow-sm">
|
||||
<i class="bi bi-plus-circle me-2"></i>{% trans "Create LOP" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show rounded-3 shadow-sm border-0" role="alert">
|
||||
<i class="bi bi-info-circle me-2"></i>{{ 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="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th class="ps-4">{% trans "LOP #" %}</th>
|
||||
<th>{% trans "Date" %}</th>
|
||||
<th>{% trans "Supplier" %}</th>
|
||||
<th>{% trans "Total" %}</th>
|
||||
<th>{% trans "User" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th class="text-end pe-4">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for lop in lops %}
|
||||
<tr>
|
||||
<td class="ps-4 fw-bold">
|
||||
<a href="{% url 'lop_detail' lop.id %}" class="text-decoration-none">
|
||||
{{ lop.lop_number|default:lop.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ lop.created_at|date:"Y-m-d" }}</td>
|
||||
<td>{{ lop.supplier.name|default:"-" }}</td>
|
||||
<td class="fw-bold text-dark">{{ site_settings.currency_symbol }}{{ lop.total_amount|floatformat:3 }}</td>
|
||||
<td>
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-person me-1"></i>{{ lop.created_by.username|default:"System" }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if lop.status == 'converted' %}
|
||||
<span class="badge bg-success-subtle text-success rounded-pill px-3">{% trans "Converted" %}</span>
|
||||
{% elif lop.status == 'cancelled' %}
|
||||
<span class="badge bg-danger-subtle text-danger rounded-pill px-3">{% trans "Cancelled" %}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary-subtle text-secondary rounded-pill px-3">{% trans "Draft" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end pe-4">
|
||||
<div class="btn-group shadow-sm rounded-3">
|
||||
<a href="{% url 'lop_detail' lop.id %}" class="btn btn-sm btn-white border" title="{% trans 'View' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
{% if lop.status == 'draft' %}
|
||||
<a href="{% url 'convert_lop_to_purchase' lop.id %}" class="btn btn-sm btn-white border text-primary" title="{% trans 'Convert to Purchase Invoice' %}" onclick="return confirm('{% trans "Are you sure you want to convert this LOP to a Purchase Invoice? Stock will be updated." %}')">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if lop.status != 'converted' %}
|
||||
<button type="button" class="btn btn-sm btn-white border text-danger" data-bs-toggle="modal" data-bs-target="#deleteModal{{ lop.id }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div class="modal fade text-start" id="deleteModal{{ lop.id }}" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content border-0 shadow rounded-4">
|
||||
<div class="modal-body p-4 text-center">
|
||||
<div class="text-danger mb-3">
|
||||
<i class="bi bi-exclamation-octagon" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<h4 class="fw-bold">{% trans "Delete LOP?" %}</h4>
|
||||
<p class="text-muted">{% trans "This action cannot be undone." %}</p>
|
||||
<div class="d-grid gap-2">
|
||||
<a href="{% url 'delete_lop' lop.id %}" class="btn btn-danger rounded-3 py-2">{% trans "Yes, Delete" %}</a>
|
||||
<button type="button" class="btn btn-light rounded-3 py-2" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5">
|
||||
<p class="text-muted">{% trans "No LOPs recorded yet." %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% include "core/pagination.html" with page_obj=lops %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -45,6 +45,12 @@ urlpatterns = [
|
||||
path('api/create-sale-return/', views.create_sale_return_api, name='create_sale_return_api'),
|
||||
|
||||
# Purchases (Invoices)
|
||||
path('purchases/lops/', views.lops, name='lops'),
|
||||
path('purchases/lops/create/', views.lop_create, name='lop_create'),
|
||||
path('purchases/lops/<int:pk>/', views.lop_detail, name='lop_detail'),
|
||||
path('purchases/lops/<int:pk>/convert/', views.convert_lop_to_purchase, name='convert_lop_to_purchase'),
|
||||
path('purchases/lops/<int:pk>/delete/', views.delete_lop, name='delete_lop'),
|
||||
path('api/create-lop/', views.create_lop_api, name='create_lop_api'),
|
||||
path('purchases/create/', views.purchase_create, name='purchase_create'),
|
||||
path('purchases/<int:pk>/', views.purchase_detail, name='purchase_detail'),
|
||||
path('purchases/payment/<int:pk>/', views.add_purchase_payment, name='add_purchase_payment'),
|
||||
|
||||
120
core/views.py
120
core/views.py
@ -23,7 +23,7 @@ from .models import ( Expense, ExpenseCategory,
|
||||
SaleItem, SalePayment, SystemSetting,
|
||||
Quotation, QuotationItem,
|
||||
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem,
|
||||
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction
|
||||
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction, LOP, LOPItem
|
||||
, Device)
|
||||
import json
|
||||
from datetime import timedelta
|
||||
@ -2524,3 +2524,121 @@ def customer_display(request):
|
||||
"""
|
||||
settings = SystemSetting.objects.first()
|
||||
return render(request, 'core/customer_display.html', {'settings': settings})
|
||||
|
||||
# --- LOP Views ---
|
||||
|
||||
@login_required
|
||||
def lops(request):
|
||||
lops_qs = LOP.objects.all().select_related('supplier', 'created_by').order_by('-created_at')
|
||||
paginator = Paginator(lops_qs, 25)
|
||||
page_number = request.GET.get('page')
|
||||
lops_list = paginator.get_page(page_number)
|
||||
suppliers = Supplier.objects.all().order_by('name')
|
||||
return render(request, 'core/lops.html', {'lops': lops_list, 'suppliers': suppliers})
|
||||
|
||||
@login_required
|
||||
def lop_create(request):
|
||||
products = Product.objects.filter(is_active=True)
|
||||
suppliers = Supplier.objects.all()
|
||||
return render(request, 'core/lop_create.html', {
|
||||
'products': products,
|
||||
'suppliers': suppliers
|
||||
})
|
||||
|
||||
@login_required
|
||||
def lop_detail(request, pk):
|
||||
lop = get_object_or_404(LOP, pk=pk)
|
||||
settings = SystemSetting.objects.first()
|
||||
return render(request, 'core/lop_detail.html', {
|
||||
'lop': lop,
|
||||
'settings': settings,
|
||||
'amount_in_words': number_to_words_en(lop.total_amount)
|
||||
})
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def create_lop_api(request):
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
supplier_id = data.get('supplier_id')
|
||||
lop_number = data.get('lop_number', '')
|
||||
items = data.get('items', [])
|
||||
total_amount = data.get('total_amount', 0)
|
||||
notes = data.get('notes', '')
|
||||
|
||||
supplier = None
|
||||
if supplier_id:
|
||||
supplier = Supplier.objects.get(id=supplier_id)
|
||||
|
||||
lop = LOP.objects.create(
|
||||
supplier=supplier,
|
||||
lop_number=lop_number,
|
||||
total_amount=total_amount,
|
||||
notes=notes,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
for item in items:
|
||||
product = Product.objects.get(id=item['id'])
|
||||
LOPItem.objects.create(
|
||||
lop=lop,
|
||||
product=product,
|
||||
quantity=item['quantity'],
|
||||
cost_price=item['price'],
|
||||
line_total=item['line_total']
|
||||
)
|
||||
|
||||
return JsonResponse({'success': True, 'lop_id': lop.id})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'error': str(e)}, status=400)
|
||||
return JsonResponse({'success': False, 'error': 'Invalid request'}, status=405)
|
||||
|
||||
@login_required
|
||||
def convert_lop_to_purchase(request, pk):
|
||||
lop = get_object_or_404(LOP, pk=pk)
|
||||
if lop.status == 'converted':
|
||||
messages.warning(request, _("This LOP has already been converted to a Purchase."))
|
||||
return redirect('lops')
|
||||
|
||||
# Create Purchase from LOP
|
||||
purchase = Purchase.objects.create(
|
||||
supplier=lop.supplier,
|
||||
lop=lop,
|
||||
total_amount=lop.total_amount,
|
||||
balance_due=lop.total_amount,
|
||||
payment_type='credit', # Default to credit, user can pay later
|
||||
status='unpaid',
|
||||
notes=lop.notes,
|
||||
created_by=request.user
|
||||
)
|
||||
|
||||
# Create PurchaseItems and Update Stock
|
||||
for item in lop.items.all():
|
||||
PurchaseItem.objects.create(
|
||||
purchase=purchase,
|
||||
product=item.product,
|
||||
quantity=item.quantity,
|
||||
cost_price=item.cost_price,
|
||||
line_total=item.line_total
|
||||
)
|
||||
# Add to Stock
|
||||
item.product.stock_quantity += item.quantity
|
||||
item.product.save()
|
||||
|
||||
# Update LOP Status
|
||||
lop.status = 'converted'
|
||||
lop.save()
|
||||
|
||||
messages.success(request, _("LOP converted to Purchase Invoice successfully!"))
|
||||
return redirect('purchase_detail', pk=purchase.pk)
|
||||
|
||||
@login_required
|
||||
def delete_lop(request, pk):
|
||||
lop = get_object_or_404(LOP, pk=pk)
|
||||
if lop.status == 'converted':
|
||||
messages.error(request, _("Cannot delete a converted LOP."))
|
||||
return redirect('lops')
|
||||
lop.delete()
|
||||
messages.success(request, _("LOP deleted successfully!"))
|
||||
return redirect('lops')
|
||||
|
||||
@ -6,6 +6,11 @@ import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
|
||||
# Monkey-patch MySQLdb version to satisfy Django's requirements
|
||||
import MySQLdb
|
||||
if not hasattr(MySQLdb, 'version_info'):
|
||||
MySQLdb.version_info = (1, 4, 6, 'final', 0)
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
Django==5.2.7
|
||||
Django>=5.0,<6.0
|
||||
pymysql
|
||||
python-dotenv==1.1.1
|
||||
pyzk==0.9
|
||||
requests==2.32.3
|
||||
python-dotenv
|
||||
# pyzk <-- Commented out: Likely incompatible with Python 3.14 on Windows
|
||||
requests
|
||||
Loading…
x
Reference in New Issue
Block a user