Создано приложение POS с планшетным интерфейсом терминала продаж
This commit is contained in:
0
myproject/pos/__init__.py
Normal file
0
myproject/pos/__init__.py
Normal file
3
myproject/pos/admin.py
Normal file
3
myproject/pos/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# POS admin (placeholder)
|
||||
7
myproject/pos/apps.py
Normal file
7
myproject/pos/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PosConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'pos'
|
||||
verbose_name = 'POS Terminal'
|
||||
0
myproject/pos/migrations/__init__.py
Normal file
0
myproject/pos/migrations/__init__.py
Normal file
3
myproject/pos/models.py
Normal file
3
myproject/pos/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# POS app models (placeholder for future extensions)
|
||||
53
myproject/pos/static/pos/css/terminal.css
Normal file
53
myproject/pos/static/pos/css/terminal.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* POS Terminal Styles */
|
||||
|
||||
body {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.pos-container {
|
||||
max-width: 100%;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.product-card {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #dee2e6;
|
||||
background: white;
|
||||
height: 100%;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.product-card.selected {
|
||||
background: #e7f3ff;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.product-card .card-body {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.product-stock {
|
||||
font-size: 0.9rem;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
170
myproject/pos/static/pos/js/terminal.js
Normal file
170
myproject/pos/static/pos/js/terminal.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// POS Terminal JavaScript
|
||||
|
||||
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
|
||||
const PRODUCTS = JSON.parse(document.getElementById('productsData').textContent);
|
||||
|
||||
let currentCategoryId = null;
|
||||
const cart = new Map(); // productId -> {id, name, price, qty}
|
||||
|
||||
function formatMoney(v) {
|
||||
return (Number(v)).toFixed(2);
|
||||
}
|
||||
|
||||
function renderProducts() {
|
||||
const grid = document.getElementById('productGrid');
|
||||
grid.innerHTML = '';
|
||||
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||
|
||||
let filtered = currentCategoryId
|
||||
? PRODUCTS.filter(p => (p.category_ids || []).includes(currentCategoryId))
|
||||
: PRODUCTS;
|
||||
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(p => p.name.toLowerCase().includes(searchTerm));
|
||||
}
|
||||
|
||||
filtered.forEach(p => {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-6 col-sm-4 col-md-3 col-lg-2';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card product-card';
|
||||
card.onclick = () => addToCart(p);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'card-body';
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'product-name';
|
||||
name.textContent = p.name;
|
||||
|
||||
const stock = document.createElement('div');
|
||||
stock.className = 'product-stock';
|
||||
stock.textContent = 'В наличии';
|
||||
|
||||
body.appendChild(name);
|
||||
body.appendChild(stock);
|
||||
card.appendChild(body);
|
||||
col.appendChild(card);
|
||||
grid.appendChild(col);
|
||||
});
|
||||
}
|
||||
|
||||
function addToCart(p) {
|
||||
if (!cart.has(p.id)) {
|
||||
cart.set(p.id, { id: p.id, name: p.name, price: Number(p.price), qty: 1 });
|
||||
} else {
|
||||
cart.get(p.id).qty += 1;
|
||||
}
|
||||
renderCart();
|
||||
}
|
||||
|
||||
function updateQty(id, delta) {
|
||||
if (!cart.has(id)) return;
|
||||
const item = cart.get(id);
|
||||
item.qty += delta;
|
||||
if (item.qty <= 0) cart.delete(id);
|
||||
renderCart();
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
const list = document.getElementById('cartList');
|
||||
list.innerHTML = '';
|
||||
let total = 0;
|
||||
|
||||
if (cart.size === 0) {
|
||||
list.innerHTML = '<p class="text-muted text-center py-4 small">Корзина пуста</p>';
|
||||
document.getElementById('cartTotal').textContent = '0.00';
|
||||
return;
|
||||
}
|
||||
|
||||
cart.forEach(item => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mb-2 pb-2 border-bottom';
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'd-flex justify-content-between align-items-start mb-1';
|
||||
nameRow.innerHTML = `
|
||||
<div class="fw-semibold small">${item.name}</div>
|
||||
<button class="btn btn-sm btn-link text-danger p-0 ms-2" onclick="event.stopPropagation(); removeFromCart(${item.id});">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const controlsRow = document.createElement('div');
|
||||
controlsRow.className = 'd-flex justify-content-between align-items-center';
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'btn-group btn-group-sm';
|
||||
|
||||
const minus = document.createElement('button');
|
||||
minus.className = 'btn btn-outline-secondary';
|
||||
minus.innerHTML = '<i class="bi bi-dash"></i>';
|
||||
minus.onclick = (e) => { e.stopPropagation(); updateQty(item.id, -1); };
|
||||
|
||||
const qtySpan = document.createElement('button');
|
||||
qtySpan.className = 'btn btn-outline-secondary disabled';
|
||||
qtySpan.textContent = item.qty;
|
||||
|
||||
const plus = document.createElement('button');
|
||||
plus.className = 'btn btn-outline-secondary';
|
||||
plus.innerHTML = '<i class="bi bi-plus"></i>';
|
||||
plus.onclick = (e) => { e.stopPropagation(); updateQty(item.id, +1); };
|
||||
|
||||
controls.appendChild(minus);
|
||||
controls.appendChild(qtySpan);
|
||||
controls.appendChild(plus);
|
||||
|
||||
const priceDiv = document.createElement('div');
|
||||
priceDiv.className = 'text-end small';
|
||||
priceDiv.innerHTML = `<strong>${formatMoney(item.price * item.qty)}</strong>`;
|
||||
|
||||
controlsRow.appendChild(controls);
|
||||
controlsRow.appendChild(priceDiv);
|
||||
|
||||
row.appendChild(nameRow);
|
||||
row.appendChild(controlsRow);
|
||||
list.appendChild(row);
|
||||
|
||||
total += item.qty * item.price;
|
||||
});
|
||||
|
||||
document.getElementById('cartTotal').textContent = formatMoney(total);
|
||||
}
|
||||
|
||||
function removeFromCart(id) {
|
||||
cart.delete(id);
|
||||
renderCart();
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
cart.clear();
|
||||
renderCart();
|
||||
}
|
||||
|
||||
document.getElementById('clearCart').onclick = clearCart;
|
||||
|
||||
// Заглушки для функционала (будет реализовано позже)
|
||||
document.getElementById('checkoutNow').onclick = async () => {
|
||||
alert('Функционал будет подключен позже: создание заказа и списание со склада.');
|
||||
};
|
||||
|
||||
document.getElementById('scheduleLater').onclick = async () => {
|
||||
alert('Функционал будет подключен позже: создание заказа на доставку/самовывоз.');
|
||||
};
|
||||
|
||||
// Categories removed from this view - can be added as filter dropdown later if needed
|
||||
|
||||
// Search functionality
|
||||
document.getElementById('searchInput').addEventListener('input', () => {
|
||||
renderProducts();
|
||||
});
|
||||
|
||||
// Customer selection
|
||||
document.getElementById('customerSelectBtn').addEventListener('click', () => {
|
||||
alert('Функция выбора клиента будет реализована позже');
|
||||
});
|
||||
|
||||
// Инициализация
|
||||
renderProducts();
|
||||
renderCart();
|
||||
122
myproject/pos/templates/pos/terminal.html
Normal file
122
myproject/pos/templates/pos/terminal.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
{% block title %}POS Terminal{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'pos/css/terminal.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Main Content -->
|
||||
<div class="pos-container">
|
||||
<div class="row g-3">
|
||||
<!-- Products Grid (Left side - 8/12) -->
|
||||
<div class="col-md-8">
|
||||
<!-- Search Box -->
|
||||
<div class="mb-3">
|
||||
<input type="text" class="form-control" id="searchInput" placeholder="Поиск по товарам...">
|
||||
</div>
|
||||
|
||||
<div class="row g-3" id="productGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel (4/12) -->
|
||||
<div class="col-md-4">
|
||||
<!-- Cart Panel -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Корзина</h6>
|
||||
<button class="btn btn-outline-primary btn-sm" id="customerSelectBtn">
|
||||
<i class="bi bi-person"></i> Выбрать клиента
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column" style="min-height: 300px;">
|
||||
<div id="cartList" class="flex-grow-1 mb-3" style="overflow-y: auto; max-height: 300px;"></div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 pb-3 border-top pt-3">
|
||||
<h5 class="mb-0">Итого:</h5>
|
||||
<h5 class="mb-0 text-primary" id="cartTotal">0.00</h5>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">Способ оплаты</label>
|
||||
<select class="form-select form-select-sm" id="paymentMethod">
|
||||
<option value="cash_to_courier">Наличные</option>
|
||||
<option value="card_to_courier">Карта</option>
|
||||
<option value="online">Онлайн</option>
|
||||
<option value="bank_transfer">Банк. перевод</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-success" id="checkoutNow">
|
||||
<i class="bi bi-check2-circle"></i> Продать
|
||||
</button>
|
||||
<button class="btn btn-outline-primary btn-sm" id="scheduleLater">
|
||||
<i class="bi bi-calendar2"></i> Запланировать
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" id="clearCart">
|
||||
<i class="bi bi-trash"></i> Очистить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons Panel -->
|
||||
<div class="card">
|
||||
<div class="card-body p-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<button class="btn btn-outline-secondary rounded-3 w-100" style="height: 60px;">
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<!-- Hidden data containers for JavaScript -->
|
||||
<script id="categoriesData" type="application/json">{{ categories_json|safe }}</script>
|
||||
<script id="productsData" type="application/json">{{ products_json|safe }}</script>
|
||||
|
||||
<script src="{% static 'pos/js/terminal.js' %}"></script>
|
||||
{% endblock %}
|
||||
9
myproject/pos/urls.py
Normal file
9
myproject/pos/urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'pos'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.pos_terminal, name='terminal'),
|
||||
]
|
||||
30
myproject/pos/views.py
Normal file
30
myproject/pos/views.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from django.shortcuts import render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from products.models import Product, ProductCategory
|
||||
import json
|
||||
|
||||
|
||||
@login_required
|
||||
def pos_terminal(request):
|
||||
"""
|
||||
Tablet-friendly POS screen prototype.
|
||||
Shows categories and in-stock products for quick tap-to-add.
|
||||
"""
|
||||
categories_qs = ProductCategory.objects.filter(is_active=True)
|
||||
products_qs = Product.objects.filter(in_stock=True).prefetch_related('categories')
|
||||
|
||||
categories = [{'id': c.id, 'name': c.name} for c in categories_qs]
|
||||
products = [{
|
||||
'id': p.id,
|
||||
'name': p.name,
|
||||
'price': str(p.actual_price),
|
||||
'category_ids': [c.id for c in p.categories.all()]
|
||||
} for p in products_qs]
|
||||
|
||||
context = {
|
||||
'categories_json': json.dumps(categories),
|
||||
'products_json': json.dumps(products),
|
||||
'title': 'POS Terminal',
|
||||
}
|
||||
return render(request, 'pos/terminal.html', context)
|
||||
Reference in New Issue
Block a user