Autosave: 20260206-074716

This commit is contained in:
Flatlogic Bot 2026-02-06 07:47:17 +00:00
parent bdde0c2da9
commit 40404a2947
23 changed files with 667 additions and 8 deletions

Binary file not shown.

View File

@ -8,7 +8,8 @@ from .models import (
SaleReturn, SaleReturnItem, SaleReturn, SaleReturnItem,
PurchaseReturn, PurchaseReturnItem, PurchaseReturn, PurchaseReturnItem,
SystemSetting, PaymentMethod, HeldSale, SystemSetting, PaymentMethod, HeldSale,
LoyaltyTier, LoyaltyTransaction LoyaltyTier, LoyaltyTransaction,
CashierCounterRegistry
) )
@admin.register(Category) @admin.register(Category)
@ -94,3 +95,8 @@ class DeviceAdmin(admin.ModelAdmin):
list_display = ('name', 'device_type', 'connection_type', 'ip_address', 'is_active') list_display = ('name', 'device_type', 'connection_type', 'ip_address', 'is_active')
list_filter = ('device_type', 'connection_type', 'is_active') list_filter = ('device_type', 'connection_type', 'is_active')
search_fields = ('name', 'ip_address') 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
View 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}),
}

View File

@ -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',
},
),
]

View 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')),
],
),
]

View File

@ -412,6 +412,7 @@ class SystemSetting(models.Model):
class Device(models.Model): class Device(models.Model):
DEVICE_TYPES = [ DEVICE_TYPES = [
('counter', _('POS Counter')),
('printer', _('Printer')), ('printer', _('Printer')),
('scanner', _('Scanner')), ('scanner', _('Scanner')),
('scale', _('Weight Scale')), ('scale', _('Weight Scale')),
@ -445,6 +446,35 @@ class UserProfile(models.Model):
def __str__(self): def __str__(self):
return self.user.username 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) @receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs): def create_user_profile(sender, instance, created, **kwargs):
if created: if created:

View File

@ -296,11 +296,11 @@
<!-- System Group --> <!-- System Group -->
<li class="sidebar-group-header mt-2"> <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> <span>{% trans "System" %}</span>
<i class="bi bi-chevron-down chevron"></i> <i class="bi bi-chevron-down chevron"></i>
</a> </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> <li>
<a href="{% url 'settings' %}" class="{% if url_name == 'settings' %}active{% endif %}"> <a href="{% url 'settings' %}" class="{% if url_name == 'settings' %}active{% endif %}">
<i class="bi bi-gear"></i> {% trans "Settings" %} <i class="bi bi-gear"></i> {% trans "Settings" %}
@ -311,6 +311,16 @@
<i class="bi bi-person-lock"></i> {% trans "User Management" %} <i class="bi bi-person-lock"></i> {% trans "User Management" %}
</a> </a>
</li> </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> <li>
<a href="/admin/"> <a href="/admin/">
<i class="bi bi-shield-lock"></i> {% trans "Django Admin" %} <i class="bi bi-shield-lock"></i> {% trans "Django Admin" %}

View 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 %}

View 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 %}

View File

@ -82,7 +82,9 @@
<h5 class="fw-bold mb-0">{% trans "Sales Revenue" %}</h5> <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> <span class="badge bg-primary bg-opacity-10 text-primary">{% trans "Last 7 Days" %}</span>
</div> </div>
<canvas id="salesChart" height="300"></canvas> <div style="position: relative; height: 300px; width: 100%;">
<canvas id="salesChart"></canvas>
</div>
</div> </div>
</div> </div>
@ -110,6 +112,13 @@
<span class="badge bg-danger rounded-pill">{{ product.stock_quantity }}</span> <span class="badge bg-danger rounded-pill">{{ product.stock_quantity }}</span>
</li> </li>
{% endfor %} {% 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> </ul>
{% else %} {% else %}
<div class="text-center py-5"> <div class="text-center py-5">

View File

@ -336,6 +336,11 @@
<small class="text-muted">{% now "l, j F Y" %}</small> <small class="text-muted">{% now "l, j F Y" %}</small>
</div> </div>
<div class="d-flex gap-2"> <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' %}"> <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> <i class="bi bi-display text-primary"></i> <span class="d-none d-md-inline ms-1 small fw-bold">{% trans "Screen" %}</span>
</button> </button>

View 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 %}

View 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 %}

View 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 %}

View File

@ -362,6 +362,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Type" %}</label> <label class="form-label fw-semibold">{% trans "Type" %}</label>
<select name="device_type" class="form-select"> <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="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="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> <option value="scale" {% if device.device_type == 'scale' %}selected{% endif %}>{% trans "Weight Scale" %}</option>
@ -722,6 +723,7 @@
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label fw-semibold">{% trans "Type" %}</label> <label class="form-label fw-semibold">{% trans "Type" %}</label>
<select name="device_type" class="form-select"> <select name="device_type" class="form-select">
<option value="counter">{% trans "POS Counter" %}</option>
<option value="printer">{% trans "Printer" %}</option> <option value="printer">{% trans "Printer" %}</option>
<option value="scanner">{% trans "Scanner" %}</option> <option value="scanner">{% trans "Scanner" %}</option>
<option value="scale">{% trans "Weight Scale" %}</option> <option value="scale">{% trans "Weight Scale" %}</option>

View File

@ -140,4 +140,11 @@ urlpatterns = [
path('purchases/lpo/convert/<int:pk>/', views.convert_lpo_to_purchase, name='convert_lpo_to_purchase'), 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('purchases/lpo/delete/<int:pk>/', views.lpo_delete, name='lpo_delete'),
path('api/create-lpo/', views.create_lpo_api, name='create_lpo_api'), 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'),
] ]

View File

@ -23,7 +23,7 @@ from .models import ( Expense, ExpenseCategory,
Quotation, QuotationItem, Quotation, QuotationItem,
SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem, PurchaseOrder, PurchaseOrderItem, SaleReturn, SaleReturnItem, PurchaseReturn, PurchaseReturnItem, PurchaseOrder, PurchaseOrderItem,
PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction PaymentMethod, HeldSale, LoyaltyTier, LoyaltyTransaction
, Device) , Device, CashierCounterRegistry)
import json import json
from datetime import timedelta from datetime import timedelta
from django.utils import timezone 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() expired_count = Product.objects.filter(has_expiry=True, expiry_date__lt=today, stock_quantity__gt=0).count()
# Stock Alert (Low stock < 5) # 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 Transactions
recent_sales = Sale.objects.order_by('-created_at').select_related('created_by')[:5] 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_count': total_sales_count,
'total_sales_amount': total_sales_amount, 'total_sales_amount': total_sales_amount,
'total_customers': total_customers, '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, 'recent_sales': recent_sales,
'chart_labels': json.dumps(chart_labels), 'chart_labels': json.dumps(chart_labels),
'chart_data': json.dumps(chart_data), 'chart_data': json.dumps(chart_data),
@ -126,6 +130,15 @@ def inventory(request):
@login_required @login_required
def pos(request): 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) products = Product.objects.all().filter(stock_quantity__gt=0, is_active=True)
customers = Customer.objects.all() customers = Customer.objects.all()
categories = Category.objects.all() categories = Category.objects.all()
@ -142,7 +155,8 @@ def pos(request):
'customers': customers, 'customers': customers,
'categories': categories, 'categories': categories,
'payment_methods': payment_methods, 'payment_methods': payment_methods,
'settings': settings 'settings': settings,
'active_session': active_session
} }
return render(request, 'core/pos.html', context) return render(request, 'core/pos.html', context)
@ -2746,3 +2760,141 @@ def lpo_delete(request, pk):
order.delete() order.delete()
messages.success(request, _("LPO deleted.")) messages.success(request, _("LPO deleted."))
return redirect('lpo_list') 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()
})