Autosave: 20260206-074716
This commit is contained in:
parent
bdde0c2da9
commit
40404a2947
Binary file not shown.
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
BIN
core/__pycache__/forms.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -8,7 +8,8 @@ from .models import (
|
||||
SaleReturn, SaleReturnItem,
|
||||
PurchaseReturn, PurchaseReturnItem,
|
||||
SystemSetting, PaymentMethod, HeldSale,
|
||||
LoyaltyTier, LoyaltyTransaction
|
||||
LoyaltyTier, LoyaltyTransaction,
|
||||
CashierCounterRegistry
|
||||
)
|
||||
|
||||
@admin.register(Category)
|
||||
@ -94,3 +95,8 @@ class DeviceAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'device_type', 'connection_type', 'ip_address', 'is_active')
|
||||
list_filter = ('device_type', 'connection_type', 'is_active')
|
||||
search_fields = ('name', 'ip_address')
|
||||
|
||||
@admin.register(CashierCounterRegistry)
|
||||
class CashierCounterRegistryAdmin(admin.ModelAdmin):
|
||||
list_display = ('cashier', 'counter', 'assigned_at')
|
||||
search_fields = ('cashier__username', 'cashier__first_name', 'counter__name')
|
||||
20
core/forms.py
Normal file
20
core/forms.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django import forms
|
||||
from .models import CashierSession
|
||||
|
||||
class CashierSessionStartForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CashierSession
|
||||
fields = ['opening_balance', 'notes']
|
||||
widgets = {
|
||||
'opening_balance': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
|
||||
class CashierSessionCloseForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CashierSession
|
||||
fields = ['closing_balance', 'notes']
|
||||
widgets = {
|
||||
'closing_balance': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001'}),
|
||||
'notes': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-06 06:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0026_purchaseorder_purchase_purchase_order_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='device_type',
|
||||
field=models.CharField(choices=[('counter', 'POS Counter'), ('printer', 'Printer'), ('scanner', 'Scanner'), ('scale', 'Weight Scale'), ('display', 'Customer Display'), ('other', 'Other')], max_length=20, verbose_name='Device Type'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CashierCounterRegistry',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='Assigned At')),
|
||||
('cashier', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='counter_assignment', to=settings.AUTH_USER_MODEL, verbose_name='Cashier')),
|
||||
('counter', models.ForeignKey(limit_choices_to={'device_type': 'counter'}, on_delete=django.db.models.deletion.CASCADE, related_name='assigned_cashiers', to='core.device', verbose_name='Counter')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Cashier Counter Registry',
|
||||
'verbose_name_plural': 'Cashier Counter Registries',
|
||||
},
|
||||
),
|
||||
]
|
||||
30
core/migrations/0028_cashiersession.py
Normal file
30
core/migrations/0028_cashiersession.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.7 on 2026-02-06 07:28
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0027_alter_device_device_type_cashiercounterregistry'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CashierSession',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('start_time', models.DateTimeField(auto_now_add=True, verbose_name='Start Time')),
|
||||
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='End Time')),
|
||||
('opening_balance', models.DecimalField(decimal_places=3, default=0, max_digits=15, verbose_name='Opening Balance')),
|
||||
('closing_balance', models.DecimalField(blank=True, decimal_places=3, max_digits=15, null=True, verbose_name='Closing Balance')),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('closed', 'Closed')], default='active', max_length=20, verbose_name='Status')),
|
||||
('notes', models.TextField(blank=True, verbose_name='Notes')),
|
||||
('counter', models.ForeignKey(blank=True, limit_choices_to={'device_type': 'counter'}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sessions', to='core.device', verbose_name='Counter')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to=settings.AUTH_USER_MODEL, verbose_name='Cashier')),
|
||||
],
|
||||
),
|
||||
]
|
||||
Binary file not shown.
BIN
core/migrations/__pycache__/0028_cashiersession.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0028_cashiersession.cpython-311.pyc
Normal file
Binary file not shown.
@ -412,6 +412,7 @@ class SystemSetting(models.Model):
|
||||
|
||||
class Device(models.Model):
|
||||
DEVICE_TYPES = [
|
||||
('counter', _('POS Counter')),
|
||||
('printer', _('Printer')),
|
||||
('scanner', _('Scanner')),
|
||||
('scale', _('Weight Scale')),
|
||||
@ -445,6 +446,35 @@ class UserProfile(models.Model):
|
||||
def __str__(self):
|
||||
return self.user.username
|
||||
|
||||
class CashierCounterRegistry(models.Model):
|
||||
cashier = models.OneToOneField(User, on_delete=models.CASCADE, related_name="counter_assignment", verbose_name=_("Cashier"))
|
||||
counter = models.ForeignKey(Device, on_delete=models.CASCADE, related_name="assigned_cashiers", verbose_name=_("Counter"), limit_choices_to={'device_type': 'counter'})
|
||||
assigned_at = models.DateTimeField(_("Assigned At"), auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Cashier Counter Registry")
|
||||
verbose_name_plural = _("Cashier Counter Registries")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.cashier.username} - {self.counter.name}"
|
||||
|
||||
class CashierSession(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('active', _('Active')),
|
||||
('closed', _('Closed')),
|
||||
]
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sessions', verbose_name=_("Cashier"))
|
||||
counter = models.ForeignKey(Device, on_delete=models.SET_NULL, null=True, blank=True, related_name='sessions', verbose_name=_("Counter"), limit_choices_to={'device_type': 'counter'})
|
||||
start_time = models.DateTimeField(_("Start Time"), auto_now_add=True)
|
||||
end_time = models.DateTimeField(_("End Time"), null=True, blank=True)
|
||||
opening_balance = models.DecimalField(_("Opening Balance"), max_digits=15, decimal_places=3, default=0)
|
||||
closing_balance = models.DecimalField(_("Closing Balance"), max_digits=15, decimal_places=3, null=True, blank=True)
|
||||
status = models.CharField(_("Status"), max_length=20, choices=STATUS_CHOICES, default='active')
|
||||
notes = models.TextField(_("Notes"), blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.start_time.strftime('%Y-%m-%d %H:%M')}"
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
|
||||
@ -296,11 +296,11 @@
|
||||
|
||||
<!-- System Group -->
|
||||
<li class="sidebar-group-header mt-2">
|
||||
<a href="#systemSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'settings' or url_name == 'user_management' or '/admin/' in path %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<a href="#systemSubmenu" data-bs-toggle="collapse" aria-expanded="{% if url_name == 'settings' or url_name == 'user_management' or url_name == 'cashier_registry' or '/admin/' in path %}true{% else %}false{% endif %}" class="dropdown-toggle-custom">
|
||||
<span>{% trans "System" %}</span>
|
||||
<i class="bi bi-chevron-down chevron"></i>
|
||||
</a>
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'settings' or url_name == 'user_management' or '/admin/' in path %}show{% endif %}" id="systemSubmenu">
|
||||
<ul class="collapse list-unstyled sub-menu {% if url_name == 'settings' or url_name == 'user_management' or url_name == 'cashier_registry' or '/admin/' in path %}show{% endif %}" id="systemSubmenu">
|
||||
<li>
|
||||
<a href="{% url 'settings' %}" class="{% if url_name == 'settings' %}active{% endif %}">
|
||||
<i class="bi bi-gear"></i> {% trans "Settings" %}
|
||||
@ -311,6 +311,16 @@
|
||||
<i class="bi bi-person-lock"></i> {% trans "User Management" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'cashier_registry' %}" class="{% if url_name == 'cashier_registry' %}active{% endif %}">
|
||||
<i class="bi bi-display"></i> {% trans "Cashier Registry" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'cashier_session_list' %}" class="{% if url_name == 'cashier_session_list' or url_name == 'session_detail' %}active{% endif %}">
|
||||
<i class="bi bi-clock-history"></i> {% trans "Cashier Sessions" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/admin/">
|
||||
<i class="bi bi-shield-lock"></i> {% trans "Django Admin" %}
|
||||
|
||||
119
core/templates/core/cashier_registry.html
Normal file
119
core/templates/core/cashier_registry.html
Normal file
@ -0,0 +1,119 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block title %}{% trans "Cashier Registry" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">{% trans "Cashier Registry" %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Assignment Form -->
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">{% trans "Assign Cashier to Counter" %}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="assign">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cashier" class="form-label">{% trans "Cashier" %}</label>
|
||||
<select name="cashier_id" id="cashier" class="form-select" required>
|
||||
<option value="">{% trans "Select Cashier" %}</option>
|
||||
{% for user in cashiers %}
|
||||
<option value="{{ user.id }}">
|
||||
{{ user.first_name }} {{ user.last_name }} ({{ user.username }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="counter" class="form-label">{% trans "Counter" %}</label>
|
||||
<select name="counter_id" id="counter" class="form-select" required>
|
||||
<option value="">{% trans "Select Counter" %}</option>
|
||||
{% for counter in counters %}
|
||||
<option value="{{ counter.id }}">
|
||||
{{ counter.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">{% trans "Only devices of type 'POS Counter' are shown." %}</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100">{% trans "Assign" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Registry List -->
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">{% trans "Current Assignments" %}</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered" width="100%" cellspacing="0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Cashier" %}</th>
|
||||
<th>{% trans "Counter" %}</th>
|
||||
<th>{% trans "Assigned At" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for reg in registries %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
{% if reg.cashier.profile.image %}
|
||||
<img src="{{ reg.cashier.profile.image.url }}" class="rounded-circle me-2" width="30" height="30">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex justify-content-center align-items-center me-2" style="width: 30px; height: 30px;">
|
||||
{{ reg.cashier.username|make_list|first|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="fw-bold">{{ reg.cashier.first_name }} {{ reg.cashier.last_name }}</div>
|
||||
<div class="small text-muted">@{{ reg.cashier.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ reg.counter.name }}</td>
|
||||
<td>{{ reg.assigned_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>
|
||||
<form method="post" class="d-inline" onsubmit="return confirm('{% trans "Are you sure you want to remove this assignment?" %}');">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="delete">
|
||||
<input type="hidden" name="registry_id" value="{{ reg.id }}">
|
||||
<button type="submit" class="btn btn-danger btn-sm" title="{% trans "Remove" %}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
<i class="fas fa-clipboard-list fa-2x mb-2"></i>
|
||||
<p>{% trans "No assignments found." %}</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
55
core/templates/core/cashier_sessions.html
Normal file
55
core/templates/core/cashier_sessions.html
Normal file
@ -0,0 +1,55 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-list-alt me-2"></i> {% trans "Cashier Sessions" %}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% trans "ID" %}</th>
|
||||
<th>{% trans "Cashier" %}</th>
|
||||
<th>{% trans "Counter" %}</th>
|
||||
<th>{% trans "Start Time" %}</th>
|
||||
<th>{% trans "End Time" %}</th>
|
||||
<th>{% trans "Status" %}</th>
|
||||
<th>{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in sessions %}
|
||||
<tr>
|
||||
<td>#{{ session.id }}</td>
|
||||
<td>{{ session.user.username }}</td>
|
||||
<td>{{ session.counter.name|default:"-" }}</td>
|
||||
<td>{{ session.start_time|date:"Y-m-d H:i" }}</td>
|
||||
<td>{{ session.end_time|date:"Y-m-d H:i"|default:"-" }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{% if session.status == 'active' %}success{% else %}secondary{% endif %}">
|
||||
{{ session.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'session_detail' pk=session.pk %}" class="btn btn-sm btn-info text-white">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">{% trans "No sessions found." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -82,7 +82,9 @@
|
||||
<h5 class="fw-bold mb-0">{% trans "Sales Revenue" %}</h5>
|
||||
<span class="badge bg-primary bg-opacity-10 text-primary">{% trans "Last 7 Days" %}</span>
|
||||
</div>
|
||||
<canvas id="salesChart" height="300"></canvas>
|
||||
<div style="position: relative; height: 300px; width: 100%;">
|
||||
<canvas id="salesChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -110,6 +112,13 @@
|
||||
<span class="badge bg-danger rounded-pill">{{ product.stock_quantity }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if low_stock_count > 5 %}
|
||||
<li class="list-group-item text-center border-0 pt-3">
|
||||
<a href="{% url 'inventory' %}" class="small text-muted fw-bold">
|
||||
+ {{ low_stock_count|add:"-5" }} {% trans "more items..." %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="text-center py-5">
|
||||
|
||||
@ -336,6 +336,11 @@
|
||||
<small class="text-muted">{% now "l, j F Y" %}</small>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
{% if active_session %}
|
||||
<a href="{% url 'close_session' %}" class="btn btn-danger border shadow-sm rounded-pill px-3" title="{% trans 'Close Session' %}">
|
||||
<i class="bi bi-door-closed"></i> <span class="d-none d-md-inline ms-1 small fw-bold">{% trans "Close" %}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-light border shadow-sm rounded-pill px-3" onclick="openCustomerDisplay()" title="{% trans 'Open Customer Screen' %}">
|
||||
<i class="bi bi-display text-primary"></i> <span class="d-none d-md-inline ms-1 small fw-bold">{% trans "Screen" %}</span>
|
||||
</button>
|
||||
|
||||
58
core/templates/core/session_close.html
Normal file
58
core/templates/core/session_close.html
Normal file
@ -0,0 +1,58 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm" style="max-width: 600px; margin: 0 auto;">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0"><i class="fas fa-door-closed me-2"></i> {% trans "Close Session" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<strong>{% trans "Started At:" %}</strong><br>
|
||||
{{ session.start_time }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<strong>{% trans "Opening Balance:" %}</strong><br>
|
||||
{{ session.opening_balance }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-light mb-3">
|
||||
<div class="card-body">
|
||||
<h5>{% trans "Session Summary (System)" %}</h5>
|
||||
<table class="table table-sm">
|
||||
<tr>
|
||||
<td>{% trans "Total Sales:" %}</td>
|
||||
<td class="text-end fw-bold">{{ total_sales|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% for pm in payments %}
|
||||
<tr>
|
||||
<td>{{ pm.payment_method_name }}</td>
|
||||
<td class="text-end">{{ pm.total|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Closing Cash Balance (Counted)" %}</label>
|
||||
{{ form.closing_balance }}
|
||||
<div class="form-text">{% trans "Enter the actual cash amount found in the drawer." %}</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Notes" %}</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-danger">{% trans "Close Session" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
89
core/templates/core/session_detail.html
Normal file
89
core/templates/core/session_detail.html
Normal file
@ -0,0 +1,89 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>{% trans "Session Details" %} #{{ session.id }}</h2>
|
||||
<a href="{% url 'cashier_session_list' %}" class="btn btn-secondary">{% trans "Back to List" %}</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">{% trans "Information" %}</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<th>{% trans "Cashier:" %}</th>
|
||||
<td>{{ session.user.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Counter:" %}</th>
|
||||
<td>{{ session.counter.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Status:" %}</th>
|
||||
<td>
|
||||
<span class="badge bg-{% if session.status == 'active' %}success{% else %}secondary{% endif %}">
|
||||
{{ session.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Start Time:" %}</th>
|
||||
<td>{{ session.start_time }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "End Time:" %}</th>
|
||||
<td>{{ session.end_time|default:"-" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header">{% trans "Financials" %}</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-bordered">
|
||||
<tr>
|
||||
<th>{% trans "Opening Balance" %}</th>
|
||||
<td class="text-end">{{ session.opening_balance|floatformat:3 }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{% trans "Total Sales (System)" %}</th>
|
||||
<td class="text-end">{{ total_sales|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% for pm in payments %}
|
||||
<tr>
|
||||
<td class="ps-4 text-muted">{{ pm.payment_method_name }}</td>
|
||||
<td class="text-end text-muted">{{ pm.total|floatformat:3 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if session.closing_balance %}
|
||||
<tr class="table-info">
|
||||
<th>{% trans "Closing Balance (Actual)" %}</th>
|
||||
<td class="text-end fw-bold">{{ session.closing_balance|floatformat:3 }}</td>
|
||||
</tr>
|
||||
|
||||
{# Calculate discrepancy for Cash if possible #}
|
||||
{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if session.notes %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header">{% trans "Notes" %}</div>
|
||||
<div class="card-body">
|
||||
{{ session.notes|linebreaks }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
33
core/templates/core/session_start.html
Normal file
33
core/templates/core/session_start.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load i18n static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-4">
|
||||
<div class="card shadow-sm" style="max-width: 500px; margin: 0 auto;">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="fas fa-cash-register me-2"></i> {% trans "Start Cashier Session" %}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<strong>{% trans "Counter:" %}</strong> {{ counter.name }} <br>
|
||||
<strong>{% trans "Cashier:" %}</strong> {{ request.user.username }}
|
||||
</div>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Opening Cash Balance" %}</label>
|
||||
{{ form.opening_balance }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">{% trans "Notes" %}</label>
|
||||
{{ form.notes }}
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-success">{% trans "Open Session" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -362,6 +362,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Type" %}</label>
|
||||
<select name="device_type" class="form-select">
|
||||
<option value="counter" {% if device.device_type == 'counter' %}selected{% endif %}>{% trans "POS Counter" %}</option>
|
||||
<option value="printer" {% if device.device_type == 'printer' %}selected{% endif %}>{% trans "Printer" %}</option>
|
||||
<option value="scanner" {% if device.device_type == 'scanner' %}selected{% endif %}>{% trans "Scanner" %}</option>
|
||||
<option value="scale" {% if device.device_type == 'scale' %}selected{% endif %}>{% trans "Weight Scale" %}</option>
|
||||
@ -722,6 +723,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">{% trans "Type" %}</label>
|
||||
<select name="device_type" class="form-select">
|
||||
<option value="counter">{% trans "POS Counter" %}</option>
|
||||
<option value="printer">{% trans "Printer" %}</option>
|
||||
<option value="scanner">{% trans "Scanner" %}</option>
|
||||
<option value="scale">{% trans "Weight Scale" %}</option>
|
||||
|
||||
@ -140,4 +140,11 @@ urlpatterns = [
|
||||
path('purchases/lpo/convert/<int:pk>/', views.convert_lpo_to_purchase, name='convert_lpo_to_purchase'),
|
||||
path('purchases/lpo/delete/<int:pk>/', views.lpo_delete, name='lpo_delete'),
|
||||
path('api/create-lpo/', views.create_lpo_api, name='create_lpo_api'),
|
||||
path('settings/cashier-registry/', views.cashier_registry, name='cashier_registry'),
|
||||
|
||||
# Cashier Sessions
|
||||
path('sessions/', views.cashier_session_list, name='cashier_session_list'),
|
||||
path('sessions/start/', views.start_session, name='start_session'),
|
||||
path('sessions/close/', views.close_session, name='close_session'),
|
||||
path('sessions/<int:pk>/', views.session_detail, name='session_detail'),
|
||||
]
|
||||
160
core/views.py
160
core/views.py
@ -23,7 +23,7 @@ from .models import ( Expense, ExpenseCategory,
|
||||
Quotation, QuotationItem,
|
||||
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem, PurchaseOrder, PurchaseOrderItem,
|
||||
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction
|
||||
, Device)
|
||||
, Device, CashierCounterRegistry)
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
@ -47,7 +47,9 @@ def index(request):
|
||||
expired_count = Product.objects.filter(has_expiry=True, expiry_date__lt=today, stock_quantity__gt=0).count()
|
||||
|
||||
# Stock Alert (Low stock < 5)
|
||||
low_stock_products = Product.objects.filter(stock_quantity__lt=5)
|
||||
low_stock_qs = Product.objects.filter(stock_quantity__lt=5)
|
||||
low_stock_count = low_stock_qs.count()
|
||||
low_stock_products = low_stock_qs[:5]
|
||||
|
||||
# Recent Transactions
|
||||
recent_sales = Sale.objects.order_by('-created_at').select_related('created_by')[:5]
|
||||
@ -75,7 +77,9 @@ def index(request):
|
||||
'total_sales_count': total_sales_count,
|
||||
'total_sales_amount': total_sales_amount,
|
||||
'total_customers': total_customers,
|
||||
'low_stock_products': low_stock_products, 'expired_count': expired_count,
|
||||
'low_stock_products': low_stock_products,
|
||||
'low_stock_count': low_stock_count,
|
||||
'expired_count': expired_count,
|
||||
'recent_sales': recent_sales,
|
||||
'chart_labels': json.dumps(chart_labels),
|
||||
'chart_data': json.dumps(chart_data),
|
||||
@ -126,6 +130,15 @@ def inventory(request):
|
||||
|
||||
@login_required
|
||||
def pos(request):
|
||||
from .models import CashierSession
|
||||
# Check for active session
|
||||
active_session = CashierSession.objects.filter(user=request.user, status='active').first()
|
||||
if not active_session:
|
||||
# Check if user is a cashier (assigned to a counter)
|
||||
if hasattr(request.user, 'counter_assignment'):
|
||||
messages.warning(request, _("Please open a session to start selling."))
|
||||
return redirect('start_session')
|
||||
|
||||
products = Product.objects.all().filter(stock_quantity__gt=0, is_active=True)
|
||||
customers = Customer.objects.all()
|
||||
categories = Category.objects.all()
|
||||
@ -142,7 +155,8 @@ def pos(request):
|
||||
'customers': customers,
|
||||
'categories': categories,
|
||||
'payment_methods': payment_methods,
|
||||
'settings': settings
|
||||
'settings': settings,
|
||||
'active_session': active_session
|
||||
}
|
||||
return render(request, 'core/pos.html', context)
|
||||
|
||||
@ -2746,3 +2760,141 @@ def lpo_delete(request, pk):
|
||||
order.delete()
|
||||
messages.success(request, _("LPO deleted."))
|
||||
return redirect('lpo_list')
|
||||
|
||||
|
||||
@login_required
|
||||
def cashier_registry(request):
|
||||
if not (request.user.is_superuser or request.user.groups.filter(name='admin').exists()):
|
||||
messages.error(request, _("Access denied."))
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'assign':
|
||||
cashier_id = request.POST.get('cashier_id')
|
||||
counter_id = request.POST.get('counter_id')
|
||||
|
||||
if cashier_id and counter_id:
|
||||
cashier = get_object_or_404(User, id=cashier_id)
|
||||
counter = get_object_or_404(Device, id=counter_id)
|
||||
|
||||
# Check if cashier already assigned
|
||||
CashierCounterRegistry.objects.update_or_create(
|
||||
cashier=cashier,
|
||||
defaults={'counter': counter}
|
||||
)
|
||||
messages.success(request, _("Cashier assigned to counter successfully."))
|
||||
|
||||
elif action == 'delete':
|
||||
registry_id = request.POST.get('registry_id')
|
||||
reg = get_object_or_404(CashierCounterRegistry, id=registry_id)
|
||||
reg.delete()
|
||||
messages.success(request, _("Assignment removed."))
|
||||
|
||||
return redirect('cashier_registry')
|
||||
|
||||
registries = CashierCounterRegistry.objects.select_related('cashier', 'counter').all()
|
||||
|
||||
# Cashiers not currently assigned (optional logic, but here we list all)
|
||||
counters = Device.objects.filter(device_type='counter', is_active=True)
|
||||
|
||||
all_cashiers = User.objects.filter(is_active=True).order_by('username')
|
||||
|
||||
return render(request, 'core/cashier_registry.html', {
|
||||
'registries': registries,
|
||||
'counters': counters,
|
||||
'cashiers': all_cashiers
|
||||
})
|
||||
|
||||
# --- Cashier Session Views ---
|
||||
from .forms import CashierSessionStartForm, CashierSessionCloseForm
|
||||
|
||||
@login_required
|
||||
def cashier_session_list(request):
|
||||
from .models import CashierSession
|
||||
sessions = CashierSession.objects.all().order_by('-start_time')
|
||||
return render(request, 'core/cashier_sessions.html', {'sessions': sessions})
|
||||
|
||||
@login_required
|
||||
def start_session(request):
|
||||
from .models import CashierSession
|
||||
# Check if user already has an active session
|
||||
active_session = CashierSession.objects.filter(user=request.user, status='active').first()
|
||||
if active_session:
|
||||
messages.warning(request, _("You already have an active session."))
|
||||
return redirect('pos')
|
||||
|
||||
# Check if user is assigned to a counter
|
||||
try:
|
||||
registry = request.user.counter_assignment
|
||||
except:
|
||||
messages.error(request, _("You are not assigned to any counter."))
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = CashierSessionStartForm(request.POST)
|
||||
if form.is_valid():
|
||||
session = form.save(commit=False)
|
||||
session.user = request.user
|
||||
session.counter = registry.counter
|
||||
session.save()
|
||||
messages.success(request, _("Session started successfully."))
|
||||
return redirect('pos')
|
||||
else:
|
||||
form = CashierSessionStartForm()
|
||||
|
||||
return render(request, 'core/session_start.html', {'form': form, 'counter': registry.counter})
|
||||
|
||||
@login_required
|
||||
def close_session(request):
|
||||
from .models import CashierSession
|
||||
active_session = CashierSession.objects.filter(user=request.user, status='active').first()
|
||||
if not active_session:
|
||||
messages.error(request, _("No active session found."))
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
form = CashierSessionCloseForm(request.POST, instance=active_session)
|
||||
if form.is_valid():
|
||||
session = form.save(commit=False)
|
||||
session.status = 'closed'
|
||||
session.end_time = timezone.now()
|
||||
session.save()
|
||||
messages.success(request, _("Session closed successfully."))
|
||||
return redirect('session_detail', pk=session.pk)
|
||||
else:
|
||||
form = CashierSessionCloseForm(instance=active_session)
|
||||
|
||||
# Calculate totals for information
|
||||
sales = Sale.objects.filter(created_by=request.user, created_at__gte=active_session.start_time)
|
||||
total_sales = sales.aggregate(Sum('total_amount'))['total_amount__sum'] or 0
|
||||
|
||||
# Calculate payments by method
|
||||
payments = SalePayment.objects.filter(created_by=request.user, created_at__gte=active_session.start_time).values('payment_method_name').annotate(total=Sum('amount'))
|
||||
|
||||
return render(request, 'core/session_close.html', {
|
||||
'form': form,
|
||||
'session': active_session,
|
||||
'total_sales': total_sales,
|
||||
'payments': payments
|
||||
})
|
||||
|
||||
@login_required
|
||||
def session_detail(request, pk):
|
||||
from .models import CashierSession
|
||||
session = get_object_or_404(CashierSession, pk=pk)
|
||||
|
||||
# Calculate totals
|
||||
end_time = session.end_time or timezone.now()
|
||||
sales = Sale.objects.filter(created_by=session.user, created_at__gte=session.start_time, created_at__lte=end_time)
|
||||
total_sales = sales.aggregate(Sum('total_amount'))['total_amount__sum'] or 0
|
||||
|
||||
payments = SalePayment.objects.filter(created_by=session.user, created_at__gte=session.start_time, created_at__lte=end_time).values('payment_method_name').annotate(total=Sum('amount'))
|
||||
|
||||
return render(request, 'core/session_detail.html', {
|
||||
'session': session,
|
||||
'total_sales': total_sales,
|
||||
'payments': payments,
|
||||
'sales_count': sales.count()
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user