20260421
This commit is contained in:
commit
2534096054
380
app.py
Normal file
380
app.py
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
from flask import Flask, render_template, request, redirect, url_for, flash, Response, jsonify
|
||||
import sqlite3, csv, io
|
||||
from datetime import date
|
||||
|
||||
def validate_email(email):
|
||||
if not email:
|
||||
return True
|
||||
return '@' in email and '.' in email.split('@')[-1]
|
||||
|
||||
def validate_phone(phone):
|
||||
if not phone:
|
||||
return True
|
||||
digits = ''.join(filter(str.isdigit, phone))
|
||||
return len(digits) == 11
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'stroy_key_2025'
|
||||
DB_PATH = 'stroy.db'
|
||||
|
||||
# -------------------------------------------------------
|
||||
# СОЗДАНИЕ 6 ТАБЛИЦ
|
||||
# -------------------------------------------------------
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. Типы материалов
|
||||
cur.execute("""CREATE TABLE IF NOT EXISTS material_types (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT
|
||||
)""")
|
||||
|
||||
# 2. Материалы
|
||||
cur.execute("""CREATE TABLE IF NOT EXISTS materials (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'шт',
|
||||
current_price REAL NOT NULL DEFAULT 0,
|
||||
material_type_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (material_type_id) REFERENCES material_types(id)
|
||||
)""")
|
||||
|
||||
# 3. Контрагенты (и поставщики и партнёры в одной таблице)
|
||||
# type=1 — партнёр (расходная), type=2 — поставщик (приходная)
|
||||
cur.execute("""CREATE TABLE IF NOT EXISTS counterparties (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
contact_person TEXT,
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
type INTEGER NOT NULL DEFAULT 1
|
||||
)""")
|
||||
|
||||
# 4. Заказы
|
||||
cur.execute("""CREATE TABLE IF NOT EXISTS purchase_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
number TEXT NOT NULL,
|
||||
order_date TEXT NOT NULL,
|
||||
delivery_date TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'Новый',
|
||||
counterparty_id INTEGER NOT NULL,
|
||||
FOREIGN KEY (counterparty_id) REFERENCES counterparties(id)
|
||||
)""")
|
||||
|
||||
# 5. Позиции заказа
|
||||
cur.execute("""CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL,
|
||||
material_id INTEGER NOT NULL,
|
||||
quantity REAL NOT NULL DEFAULT 1,
|
||||
unit_price REAL NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (order_id) REFERENCES purchase_orders(id),
|
||||
FOREIGN KEY (material_id) REFERENCES materials(id)
|
||||
)""")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Начальные данные
|
||||
if cur.execute("SELECT COUNT(*) FROM material_types").fetchone()[0] == 0:
|
||||
for t in [('Кирпич и блоки','Кирпич, блоки, камень'),('Цемент и смеси','Цемент, штукатурка, шпатлёвка'),
|
||||
('Пиломатериалы','Доски, брус, фанера'),('Кровельные материалы','Профнастил, черепица'),
|
||||
('Теплоизоляция','Минвата, пенопласт'),('Отделочные материалы','Плитка, гипсокартон')]:
|
||||
cur.execute("INSERT INTO material_types (name, description) VALUES (?,?)", t)
|
||||
|
||||
for m in [('Кирпич керамический М150',1,'шт',18.5),('Газобетонный блок D500',1,'шт',185.0),
|
||||
('Цемент ПЦ 500, 50 кг',2,'мешок',450.0),('Штукатурная смесь Ротбанд',2,'мешок',620.0),
|
||||
('Доска обрезная 50x150x6000',3,'шт',850.0),('Фанера ФСФ 18 мм',3,'лист',1850.0),
|
||||
('Профнастил НС35',4,'м²',580.0),('Черепица Braas',4,'шт',95.0),
|
||||
('Минвата Knauf 100 мм',5,'м²',320.0),('Пенопласт ПСБ-С 35',5,'м²',185.0),
|
||||
('Плитка керамическая 30x30',6,'м²',850.0),('Гипсокартон Knauf 12.5 мм',6,'лист',520.0)]:
|
||||
cur.execute("INSERT INTO materials (name, material_type_id, unit, current_price) VALUES (?,?,?,?)", m)
|
||||
|
||||
# type=1 партнёры, type=2 поставщики
|
||||
for c in [('ООО СтройГрупп','Иванов И.И.','+7(495)111-22-33','info@stroygroup.ru','Москва, ул. Ленина, 10',1),
|
||||
('ИП Петров А.А.','Петров А.А.','+7(903)222-33-44','petrov@mail.ru','Московская обл., г. Химки',1),
|
||||
('АО РемСтрой','Сидоров С.С.','+7(495)333-44-55','rem@stroy.ru','Москва, пр-т Мира, 55',1),
|
||||
('СтройОптТорг','Козлов К.К.','+7(495)123-45-67','info@stroyopt.ru','Москва, ул. Строителей, 15',2),
|
||||
('КирпичМастер','Морозов М.М.','+7(499)234-56-78','sales@kirpich.ru','Подольск, пр-т Ленина, 42',2),
|
||||
('ЛесТорг Урал','Лесов Л.Л.','+7(343)345-67-89','les@ural.com','Екатеринбург, ул. Лесная, 8',2)]:
|
||||
cur.execute("INSERT INTO counterparties (name,contact_person,phone,email,address,type) VALUES (?,?,?,?,?,?)", c)
|
||||
|
||||
# Заказы
|
||||
cur.execute("INSERT INTO purchase_orders (number,order_date,delivery_date,status,counterparty_id) VALUES ('ЗК-001','2026-01-15','2026-01-20','Выполнен',4)")
|
||||
cur.execute("INSERT INTO purchase_orders (number,order_date,delivery_date,status,counterparty_id) VALUES ('ЗК-002','2026-02-10','2026-02-15','Выполнен',1)")
|
||||
cur.execute("INSERT INTO purchase_orders (number,order_date,delivery_date,status,counterparty_id) VALUES ('ЗК-003','2026-03-01',NULL,'Новый',5)")
|
||||
|
||||
# Позиции заказов
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (1,1,5000,18.5)")
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (1,3,100,450.0)")
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (2,5,50,850.0)")
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (2,9,200,320.0)")
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (3,7,100,580.0)")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
# -------------------------------------------------------
|
||||
# ГЛАВНАЯ — материалы
|
||||
# -------------------------------------------------------
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
search = request.args.get('search','')
|
||||
type_filter = request.args.get('type_id','')
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
sql = "SELECT m.*, mt.name AS type_name FROM materials m JOIN material_types mt ON m.material_type_id=mt.id WHERE 1=1"
|
||||
params = []
|
||||
if search: sql += " AND m.name LIKE ?"; params.append('%'+search+'%')
|
||||
if type_filter: sql += " AND m.material_type_id=?"; params.append(type_filter)
|
||||
sql += " ORDER BY m.name"
|
||||
cur.execute(sql, params)
|
||||
materials = [dict(r) for r in cur.fetchall()]
|
||||
cur.execute("SELECT * FROM material_types ORDER BY name"); types = cur.fetchall()
|
||||
stat = {
|
||||
'materials': cur.execute("SELECT COUNT(*) FROM materials").fetchone()[0],
|
||||
'counterparties': cur.execute("SELECT COUNT(*) FROM counterparties").fetchone()[0],
|
||||
'partners': cur.execute("SELECT COUNT(*) FROM counterparties WHERE type=1").fetchone()[0],
|
||||
'suppliers': cur.execute("SELECT COUNT(*) FROM counterparties WHERE type=2").fetchone()[0],
|
||||
'orders': cur.execute("SELECT COUNT(*) FROM purchase_orders").fetchone()[0],
|
||||
}
|
||||
conn.close()
|
||||
return render_template('index.html', materials=materials, types=types, search=search, type_filter=type_filter, stat=stat)
|
||||
|
||||
@app.route('/materials/add', methods=['GET','POST'])
|
||||
def material_add():
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM material_types ORDER BY name"); types = cur.fetchall(); conn.close()
|
||||
if request.method == 'POST':
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("INSERT INTO materials (name,material_type_id,unit,current_price) VALUES (?,?,?,?)",
|
||||
(request.form['name'],request.form['material_type_id'],request.form['unit'],request.form['current_price']))
|
||||
conn.commit(); conn.close()
|
||||
flash('Материал добавлен','success'); return redirect(url_for('index'))
|
||||
return render_template('material_form.html', material=None, types=types, action='add')
|
||||
|
||||
@app.route('/materials/edit/<int:mid>', methods=['GET','POST'])
|
||||
def material_edit(mid):
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM materials WHERE id=?",(mid,)); material = cur.fetchone()
|
||||
cur.execute("SELECT * FROM material_types ORDER BY name"); types = cur.fetchall(); conn.close()
|
||||
if not material: flash('Не найдено','error'); return redirect(url_for('index'))
|
||||
if request.method == 'POST':
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("UPDATE materials SET name=?,material_type_id=?,unit=?,current_price=? WHERE id=?",
|
||||
(request.form['name'],request.form['material_type_id'],request.form['unit'],request.form['current_price'],mid))
|
||||
conn.commit(); conn.close()
|
||||
flash('Материал обновлён','success'); return redirect(url_for('index'))
|
||||
return render_template('material_form.html', material=material, types=types, action='edit')
|
||||
|
||||
# -------------------------------------------------------
|
||||
# КОНТРАГЕНТЫ
|
||||
# -------------------------------------------------------
|
||||
|
||||
@app.route('/counterparties')
|
||||
def counterparties_list():
|
||||
ctype = request.args.get('type','')
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
sql = "SELECT c.*, COUNT(o.id) AS order_count FROM counterparties c LEFT JOIN purchase_orders o ON c.id=o.counterparty_id WHERE 1=1"
|
||||
params = []
|
||||
if ctype: sql += " AND c.type=?"; params.append(ctype)
|
||||
sql += " GROUP BY c.id ORDER BY c.name"
|
||||
cur.execute(sql, params); counterparties = cur.fetchall(); conn.close()
|
||||
return render_template('counterparties.html', counterparties=counterparties, ctype=ctype)
|
||||
|
||||
@app.route('/counterparties/add', methods=['GET','POST'])
|
||||
def counterparty_add():
|
||||
is_ajax = request.args.get('ajax')=='1'
|
||||
default_type = request.args.get('type','1')
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name','').strip()
|
||||
phone = request.form.get('phone','').strip()
|
||||
email = request.form.get('email','').strip()
|
||||
|
||||
# Проверки
|
||||
if not name:
|
||||
flash('Название обязательно', 'error')
|
||||
elif phone and not validate_phone(phone):
|
||||
flash('Неверный формат телефона. Должно быть 10 или 11 цифр', 'error')
|
||||
elif email and not validate_email(email):
|
||||
flash('Неверный формат email. Нужен символ @ и точка', 'error')
|
||||
else:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("INSERT INTO counterparties (name,contact_person,phone,email,address,type) VALUES (?,?,?,?,?,?)",
|
||||
(name,request.form.get('contact_person',''),phone,email,request.form.get('address',''),request.form.get('type',1)))
|
||||
new_id = cur.lastrowid; conn.commit(); conn.close()
|
||||
if is_ajax: return jsonify({'id':new_id,'name':name})
|
||||
flash('Контрагент добавлен','success')
|
||||
return redirect(url_for('counterparties_list'))
|
||||
|
||||
return render_template('counterparty_form.html', counterparty=None, action='add', default_type=default_type)
|
||||
|
||||
@app.route('/counterparties/edit/<int:cid>', methods=['GET','POST'])
|
||||
def counterparty_edit(cid):
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM counterparties WHERE id=?",(cid,)); counterparty = cur.fetchone()
|
||||
conn.close()
|
||||
if not counterparty:
|
||||
flash('Не найдено','error')
|
||||
return redirect(url_for('counterparties_list'))
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name','').strip()
|
||||
phone = request.form.get('phone','').strip()
|
||||
email = request.form.get('email','').strip()
|
||||
|
||||
# Проверки
|
||||
if not name:
|
||||
flash('Название обязательно', 'error')
|
||||
elif phone and not validate_phone(phone):
|
||||
flash('Неверный формат телефона. Должно быть ровно 11 цифр', 'error')
|
||||
elif email and not validate_email(email):
|
||||
flash('Неверный формат email. Нужен символ @ и точка', 'error')
|
||||
else:
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("UPDATE counterparties SET name=?,contact_person=?,phone=?,email=?,address=?,type=? WHERE id=?",
|
||||
(name,request.form.get('contact_person',''),phone,email,request.form.get('address',''),request.form.get('type',1),cid))
|
||||
conn.commit(); conn.close()
|
||||
flash('Контрагент обновлён','success')
|
||||
return redirect(url_for('counterparties_list'))
|
||||
|
||||
return render_template('counterparty_form.html', counterparty=counterparty, action='edit', default_type=counterparty['type'])
|
||||
|
||||
# -------------------------------------------------------
|
||||
# ЗАКАЗЫ
|
||||
# -------------------------------------------------------
|
||||
|
||||
@app.route('/orders')
|
||||
def orders_list():
|
||||
sort = request.args.get('sort', 'date_desc')
|
||||
search = request.args.get('search', '').strip()
|
||||
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Базовый SQL с поиском
|
||||
sql = """
|
||||
SELECT o.*, c.name AS counterparty_name, c.type AS counterparty_type,
|
||||
COUNT(oi.id) AS items_count
|
||||
FROM purchase_orders o
|
||||
JOIN counterparties c ON o.counterparty_id = c.id
|
||||
LEFT JOIN order_items oi ON o.id = oi.order_id
|
||||
"""
|
||||
params = []
|
||||
|
||||
if search:
|
||||
sql += " WHERE o.number LIKE ? OR c.name LIKE ? OR o.status LIKE ?"
|
||||
search_pattern = f'%{search}%'
|
||||
params = [search_pattern, search_pattern, search_pattern]
|
||||
|
||||
sql += " GROUP BY o.id"
|
||||
|
||||
# Сортировка
|
||||
if sort == 'date_asc':
|
||||
sql += " ORDER BY o.order_date ASC"
|
||||
elif sort == 'date_desc':
|
||||
sql += " ORDER BY o.order_date DESC"
|
||||
elif sort == 'price_asc':
|
||||
sql += " ORDER BY (SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = o.id) ASC"
|
||||
elif sort == 'price_desc':
|
||||
sql += " ORDER BY (SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = o.id) DESC"
|
||||
else:
|
||||
sql += " ORDER BY o.order_date DESC"
|
||||
|
||||
cur.execute(sql, params)
|
||||
orders_raw = cur.fetchall()
|
||||
|
||||
orders = []
|
||||
for o in orders_raw:
|
||||
# Считаем итого по позициям
|
||||
cur.execute("SELECT SUM(quantity * unit_price) FROM order_items WHERE order_id = ?", (o['id'],))
|
||||
total = cur.fetchone()[0] or 0
|
||||
orders.append({**dict(o), 'total': round(total, 2)})
|
||||
|
||||
conn.close()
|
||||
|
||||
return render_template('orders_list.html', orders=orders, sort=sort, search=search)
|
||||
|
||||
|
||||
@app.route('/orders/add', methods=['GET','POST'])
|
||||
def order_add():
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM counterparties ORDER BY type, name"); counterparties = cur.fetchall()
|
||||
cur.execute("SELECT m.*, mt.name AS type_name FROM materials m JOIN material_types mt ON m.material_type_id=mt.id ORDER BY m.name")
|
||||
materials = cur.fetchall(); conn.close()
|
||||
if request.method == 'POST':
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("INSERT INTO purchase_orders (number,order_date,delivery_date,status,counterparty_id) VALUES (?,?,?,?,?)",
|
||||
(request.form['number'],request.form['order_date'],request.form.get('delivery_date',''),
|
||||
request.form.get('status','Новый'),request.form['counterparty_id']))
|
||||
order_id = cur.lastrowid
|
||||
# Сохраняем позиции
|
||||
mat_ids = request.form.getlist('material_id[]')
|
||||
quantities = request.form.getlist('quantity[]')
|
||||
prices = request.form.getlist('unit_price[]')
|
||||
for mid, qty, price in zip(mat_ids, quantities, prices):
|
||||
if mid and qty and float(qty) > 0:
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (?,?,?,?)",
|
||||
(order_id, mid, qty, price))
|
||||
conn.commit(); conn.close()
|
||||
flash('Заказ добавлен','success'); return redirect(url_for('orders_list'))
|
||||
today = date.today().strftime('%Y-%m-%d')
|
||||
return render_template('order_form.html', order=None, items=[], counterparties=counterparties, materials=materials, action='add', today=today)
|
||||
|
||||
@app.route('/orders/edit/<int:oid>', methods=['GET','POST'])
|
||||
def order_edit(oid):
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM purchase_orders WHERE id=?",(oid,)); order = cur.fetchone()
|
||||
cur.execute("SELECT oi.*, m.name AS material_name FROM order_items oi JOIN materials m ON oi.material_id=m.id WHERE oi.order_id=?",(oid,))
|
||||
items = cur.fetchall()
|
||||
cur.execute("SELECT * FROM counterparties ORDER BY type, name"); counterparties = cur.fetchall()
|
||||
cur.execute("SELECT m.*, mt.name AS type_name FROM materials m JOIN material_types mt ON m.material_type_id=mt.id ORDER BY m.name")
|
||||
materials = cur.fetchall(); conn.close()
|
||||
if not order: flash('Не найдено','error'); return redirect(url_for('orders_list'))
|
||||
if request.method == 'POST':
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("UPDATE purchase_orders SET number=?,order_date=?,delivery_date=?,status=?,counterparty_id=? WHERE id=?",
|
||||
(request.form['number'],request.form['order_date'],request.form.get('delivery_date',''),
|
||||
request.form.get('status','Новый'),request.form['counterparty_id'],oid))
|
||||
cur.execute("DELETE FROM order_items WHERE order_id=?",(oid,))
|
||||
mat_ids = request.form.getlist('material_id[]')
|
||||
quantities = request.form.getlist('quantity[]')
|
||||
prices = request.form.getlist('unit_price[]')
|
||||
for mid, qty, price in zip(mat_ids, quantities, prices):
|
||||
if mid and qty and float(qty) > 0:
|
||||
cur.execute("INSERT INTO order_items (order_id,material_id,quantity,unit_price) VALUES (?,?,?,?)",
|
||||
(oid, mid, qty, price))
|
||||
conn.commit(); conn.close()
|
||||
flash('Заказ обновлён','success'); return redirect(url_for('orders_list'))
|
||||
return render_template('order_form.html', order=order, items=items, counterparties=counterparties, materials=materials, action='edit', today=order['order_date'])
|
||||
|
||||
@app.route('/api/material/<int:mid>')
|
||||
def api_material(mid):
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT * FROM materials WHERE id=?",(mid,)); m = cur.fetchone(); conn.close()
|
||||
if not m: return jsonify({}), 404
|
||||
return jsonify({'price': m['current_price'], 'unit': m['unit'], 'name': m['name']})
|
||||
|
||||
@app.route('/export/csv')
|
||||
def export_csv():
|
||||
conn = get_db(); cur = conn.cursor()
|
||||
cur.execute("SELECT m.name, mt.name AS type_name, m.unit, m.current_price FROM materials m JOIN material_types mt ON m.material_type_id=mt.id ORDER BY m.name")
|
||||
rows = cur.fetchall(); conn.close()
|
||||
output = io.StringIO(); writer = csv.writer(output)
|
||||
writer.writerow(['Наименование','Тип','Ед.','Цена'])
|
||||
for r in rows: writer.writerow([r['name'],r['type_name'],r['unit'],r['current_price']])
|
||||
output.seek(0)
|
||||
return Response('\ufeff'+output.read(), mimetype='text/csv; charset=utf-8-sig',
|
||||
headers={'Content-Disposition':'attachment; filename=materials.csv'})
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
app.run(debug=True, port=5000)
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
flask==3.0.3
|
||||
439
static/css/style.css
Normal file
439
static/css/style.css
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
/* ============================================================
|
||||
СтройСклад — Вариант №6
|
||||
Стиль: Индустриальный, боковая навигация
|
||||
Цвета варианта: #FAEBD7, #DEB887, #A0522D
|
||||
Шрифт: Verdana
|
||||
============================================================ */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--cream: #FAEBD7;
|
||||
--sand: #DEB887;
|
||||
--brown: #A0522D;
|
||||
--dark: #1C1008;
|
||||
--white: #FFFFFF;
|
||||
--light-bg: #F9F4EE;
|
||||
--border: #E8D5BC;
|
||||
--text: #1C1008;
|
||||
--text-mid: #6B4C30;
|
||||
--text-light: #A0826A;
|
||||
--shadow: rgba(160, 82, 45, 0.10);
|
||||
--success: #3D7A35;
|
||||
--error: #B83030;
|
||||
--sidebar-w: 220px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { font-size: 14px; }
|
||||
|
||||
body {
|
||||
font-family: Verdana, Geneva, sans-serif;
|
||||
background: var(--light-bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ─── БОКОВАЯ НАВИГАЦИЯ ─────────────────────────────────────── */
|
||||
.sidebar {
|
||||
width: var(--sidebar-w);
|
||||
min-height: 100vh;
|
||||
background: var(--dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
border-right: 3px solid var(--brown);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
padding: 1.5rem 1.2rem 1.2rem;
|
||||
border-bottom: 1px solid rgba(222,184,135,0.15);
|
||||
}
|
||||
.sidebar-logo .logo-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-bottom: 0.6rem;
|
||||
display: block;
|
||||
}
|
||||
.sidebar-logo .logo-text {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--cream);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.sidebar-logo .logo-sub {
|
||||
font-size: 0.65rem;
|
||||
color: var(--sand);
|
||||
opacity: 0.7;
|
||||
letter-spacing: 0.04em;
|
||||
display: block;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.nav-section-title {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: rgba(222,184,135,0.45);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 0.8rem 1.2rem 0.3rem;
|
||||
}
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem 1.2rem;
|
||||
color: rgba(250,235,215,0.65);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: rgba(222,184,135,0.08);
|
||||
color: var(--cream);
|
||||
border-left-color: rgba(222,184,135,0.4);
|
||||
}
|
||||
.nav-link.active {
|
||||
background: rgba(160,82,45,0.25);
|
||||
color: var(--sand);
|
||||
border-left-color: var(--brown);
|
||||
font-weight: 700;
|
||||
}
|
||||
.nav-icon { font-size: 1rem; width: 18px; text-align: center; }
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem 1.2rem;
|
||||
border-top: 1px solid rgba(222,184,135,0.15);
|
||||
font-size: 0.65rem;
|
||||
color: rgba(222,184,135,0.35);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ─── ОСНОВНОЙ КОНТЕНТ ──────────────────────────────────────── */
|
||||
.main-wrapper {
|
||||
margin-left: var(--sidebar-w);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.9rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.topbar-title span {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 1.1em;
|
||||
background: var(--brown);
|
||||
border-radius: 2px;
|
||||
margin-right: 0.6rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.topbar-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.page-body {
|
||||
flex: 1;
|
||||
padding: 1.8rem 2rem;
|
||||
}
|
||||
|
||||
/* ─── FLASH ─────────────────────────────────────────────────── */
|
||||
.flash-container { margin-bottom: 1.2rem; }
|
||||
.flash {
|
||||
padding: 0.7rem 1rem 0.7rem 1.2rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.flash-success { background: #F0FAF0; border-color: var(--success); color: #2A5C25; }
|
||||
.flash-error { background: #FDF2F2; border-color: var(--error); color: var(--error); }
|
||||
|
||||
/* ─── СТАТИСТИКА ────────────────────────────────────────────── */
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.3rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: var(--brown);
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--brown);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ─── ФИЛЬТРЫ ───────────────────────────────────────────────── */
|
||||
.filter-bar {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.3rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
.filter-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.filter-group label {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.filter-btns { flex-direction: row; align-items: flex-end; }
|
||||
|
||||
/* ─── ИНПУТЫ ────────────────────────────────────────────────── */
|
||||
.form-input {
|
||||
font-family: Verdana, sans-serif;
|
||||
font-size: 0.85rem;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.48rem 0.8rem;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color 0.18s, box-shadow 0.18s;
|
||||
min-width: 130px;
|
||||
}
|
||||
.form-input:focus {
|
||||
border-color: var(--brown);
|
||||
box-shadow: 0 0 0 3px rgba(160,82,45,0.1);
|
||||
}
|
||||
.form-textarea { resize: vertical; min-height: 80px; width: 100%; }
|
||||
|
||||
/* ─── КНОПКИ ────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.48rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-family: Verdana, sans-serif;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid transparent;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.btn:hover { transform: translateY(-1px); }
|
||||
.btn:active { transform: none; }
|
||||
.btn-primary { background: var(--brown); color: var(--white); border-color: var(--brown); }
|
||||
.btn-primary:hover { background: #8B4520; border-color: #8B4520; }
|
||||
.btn-secondary { background: var(--white); color: var(--text-mid); border-color: var(--border); }
|
||||
.btn-secondary:hover { border-color: var(--brown); color: var(--brown); }
|
||||
.btn-edit { background: var(--white); color: #3A6A18; border-color: #B0D080; font-size: 0.78rem; }
|
||||
.btn-edit:hover { background: #F0FAE8; }
|
||||
.btn-danger { background: var(--white); color: var(--error); border-color: #DDAAAA; font-size: 0.78rem; }
|
||||
.btn-danger:hover { background: #FDF2F2; }
|
||||
.btn-sm { padding: 0.3rem 0.65rem; font-size: 0.76rem; }
|
||||
|
||||
/* ─── ТАБЛИЦА ───────────────────────────────────────────────── */
|
||||
.table-wrapper {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 6px var(--shadow);
|
||||
}
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
.data-table thead tr {
|
||||
background: var(--cream);
|
||||
border-bottom: 2px solid var(--sand);
|
||||
}
|
||||
.data-table th {
|
||||
padding: 0.7rem 1rem;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
font-size: 0.67rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.data-table td {
|
||||
padding: 0.62rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
color: var(--text);
|
||||
}
|
||||
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||
.data-table tbody tr:hover { background: #FBF6F0; }
|
||||
.calc-col { color: var(--text-mid); }
|
||||
.highlight-col strong { color: var(--brown); font-size: 0.92rem; }
|
||||
.low-stock { color: var(--error); font-weight: 700; }
|
||||
.table-meta {
|
||||
padding: 0.6rem 0.2rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* ─── БЕЙДЖ ─────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: var(--cream);
|
||||
color: var(--brown);
|
||||
border-radius: 4px;
|
||||
padding: 0.12rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--sand);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
code {
|
||||
background: var(--cream);
|
||||
color: var(--brown);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.76rem;
|
||||
border: 1px solid var(--sand);
|
||||
}
|
||||
|
||||
/* ─── ФОРМА ─────────────────────────────────────────────────── */
|
||||
.form-card {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 860px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.2rem;
|
||||
}
|
||||
.form-group { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
.form-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-mid);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.required { color: var(--error); }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.6rem;
|
||||
padding-top: 1.2rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ─── РАСЧЁТ ────────────────────────────────────────────────── */
|
||||
.calc-preview {
|
||||
background: var(--cream);
|
||||
border: 1px solid var(--sand);
|
||||
border-radius: 8px;
|
||||
padding: 1.2rem 1.5rem;
|
||||
margin-top: 1.3rem;
|
||||
}
|
||||
.calc-title {
|
||||
font-weight: 700;
|
||||
color: var(--brown);
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.calc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.38rem 0;
|
||||
border-bottom: 1px solid rgba(160,82,45,0.12);
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-mid);
|
||||
}
|
||||
.calc-row:last-child { border-bottom: none; }
|
||||
.total-row {
|
||||
font-weight: 700;
|
||||
color: var(--brown);
|
||||
font-size: 1rem;
|
||||
padding-top: 0.6rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.preview-qty-input { width: 80px; text-align: center; }
|
||||
|
||||
/* ─── RESPONSIVE ────────────────────────────────────────────── */
|
||||
@media (max-width: 900px) {
|
||||
.sidebar { display: none; }
|
||||
.main-wrapper { margin-left: 0; }
|
||||
.stats-bar { grid-template-columns: 1fr 1fr; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-group.full-width { grid-column: 1; }
|
||||
}
|
||||
|
||||
/* ─── СОРТИРОВКА ────────────────────────────────────────────── */
|
||||
.sort-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
13
static/js/main.js
Normal file
13
static/js/main.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// main.js — СтройСклад
|
||||
|
||||
// Подтверждение удаления (резерв)
|
||||
document.querySelectorAll('[data-confirm]').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
if (!confirm(el.dataset.confirm)) e.preventDefault();
|
||||
});
|
||||
});
|
||||
|
||||
// Подсветка строк таблицы при клике
|
||||
document.querySelectorAll('.data-table tbody tr').forEach(row => {
|
||||
row.style.cursor = 'default';
|
||||
});
|
||||
45
templates/base.html
Normal file
45
templates/base.html
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Стройка{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<svg class="logo-icon" viewBox="0 0 40 40" fill="none">
|
||||
<rect x="2" y="26" width="36" height="10" rx="2" fill="#A0522D"/>
|
||||
<rect x="6" y="16" width="28" height="10" rx="2" fill="#DEB887"/>
|
||||
<rect x="10" y="8" width="20" height="8" rx="2" fill="#FAEBD7"/>
|
||||
<rect x="17" y="26" width="6" height="10" fill="#7B3F22"/>
|
||||
</svg>
|
||||
<span class="logo-text">Стройка</span>
|
||||
<span class="logo-sub">Учёт материалов · Вариант №6</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-section-title">Разделы</div>
|
||||
<a href="{{ url_for('index') }}" class="nav-link {% if request.endpoint=='index' or 'material' in request.endpoint %}active{% endif %}"><span class="nav-icon">📦</span> Материалы</a>
|
||||
<a href="{{ url_for('counterparties_list') }}" class="nav-link {% if 'counterpart' in request.endpoint %}active{% endif %}"><span class="nav-icon">👥</span> Контрагенты</a>
|
||||
<a href="{{ url_for('orders_list') }}" class="nav-link {% if 'order' in request.endpoint %}active{% endif %}"><span class="nav-icon">📋</span> Заказы</a>
|
||||
<div class="nav-section-title">Экспорт</div>
|
||||
<a href="{{ url_for('export_csv') }}" class="nav-link"><span class="nav-icon">💾</span> Экспорт CSV</a>
|
||||
</nav>
|
||||
<div class="sidebar-footer">Строительные материалы<br>Вариант №6 · 2026</div>
|
||||
</aside>
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
<div class="topbar-title"><span></span>{% block page_title %}Стройка{% endblock %}</div>
|
||||
<div class="topbar-actions">{% block topbar_actions %}{% endblock %}</div>
|
||||
</header>
|
||||
<main class="page-body">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}<div class="flash-container">{% for cat,msg in messages %}<div class="flash flash-{{ cat }}">{{ msg }}</div>{% endfor %}</div>{% endif %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
36
templates/counterparties.html
Normal file
36
templates/counterparties.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Контрагенты — Стройка{% endblock %}
|
||||
{% block page_title %}{% if ctype=='1' %}Партнёры{% elif ctype=='2' %}Поставщики{% else %}Все контрагенты{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('counterparty_add') }}?type={{ ctype or '1' }}" class="btn btn-primary">+ Добавить</a>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="sort-bar">
|
||||
Показать:
|
||||
<a href="?type=" class="btn btn-sm {% if not ctype %}btn-primary{% else %}btn-secondary{% endif %}">Все</a>
|
||||
<a href="?type=2" class="btn btn-sm {% if ctype=='2' %}btn-primary{% else %}btn-secondary{% endif %}">🏭 Поставщики</a>
|
||||
<a href="?type=1" class="btn btn-sm {% if ctype=='1' %}btn-primary{% else %}btn-secondary{% endif %}">🤝 Партнёры</a>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Тип</th><th>Название</th><th>Контактное лицо</th><th>Телефон</th><th>Email</th><th>Адрес</th><th>Заказов</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in counterparties %}
|
||||
<tr>
|
||||
<td style="color:var(--text-light)">{{ c.id }}</td>
|
||||
<td><span class="badge">{% if c.type==2 %}Поставщик{% else %}Партнёр{% endif %}</span></td>
|
||||
<td><strong>{{ c.name }}</strong></td>
|
||||
<td>{{ c.contact_person or '—' }}</td>
|
||||
<td>{{ c.phone or '—' }}</td>
|
||||
<td>{{ c.email or '—' }}</td>
|
||||
<td>{{ c.address or '—' }}</td>
|
||||
<td><span class="badge">{{ c.order_count }}</span></td>
|
||||
<td><a href="{{ url_for('counterparty_edit', cid=c.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" style="text-align:center;padding:2rem;color:#aaa">Нет данных</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
templates/counterparty_form.html
Normal file
43
templates/counterparty_form.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новый контрагент{% else %}Редактировать{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новый контрагент{% else %}Редактировать: {{ counterparty.name }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('counterparties_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Тип <span class="required">*</span></label>
|
||||
<select name="type" class="form-input" required>
|
||||
<option value="1" {% if default_type|string=='1' %}selected{% endif %}>Партнёр (расходная)</option>
|
||||
<option value="2" {% if default_type|string=='2' %}selected{% endif %}>Поставщик (приходная)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Название <span class="required">*</span></label>
|
||||
<input type="text" name="name" class="form-input" required value="{{ counterparty.name if counterparty else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Контактное лицо</label>
|
||||
<input type="text" name="contact_person" class="form-input" value="{{ counterparty.contact_person if counterparty else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Телефон</label>
|
||||
<input type="text" name="phone" class="form-input" value="{{ counterparty.phone if counterparty else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-input" value="{{ counterparty.email if counterparty else '' }}">
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Адрес</label>
|
||||
<textarea name="address" class="form-input form-textarea">{{ counterparty.address if counterparty else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('counterparties_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
117
templates/expense_form.html
Normal file
117
templates/expense_form.html
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новая расходная{% else %}Редактировать{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новая расходная накладная{% else %}Редактировать расходную #{{ order.id }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('expense_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Номер накладной <span class="required">*</span></label>
|
||||
<input type="text" name="number" class="form-input" required value="{{ order.number if order else '' }}" placeholder="РН-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Дата <span class="required">*</span></label>
|
||||
<input type="date" name="order_date" class="form-input" required value="{{ today }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Тип материала <span class="required">*</span></label>
|
||||
<select name="type_id" class="form-input" required>
|
||||
<option value="">— выбрать —</option>
|
||||
{% for t in types %}<option value="{{ t.id }}" {% if order and order.type_id==t.id %}selected{% endif %}>{{ t.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Партнёр <span class="required">*</span></label>
|
||||
<div style="display:flex;gap:0.5rem">
|
||||
<select name="partner_id" class="form-input" required id="partner_sel">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for p in partners %}<option value="{{ p.id }}" {% if order and order.partner_id==p.id %}selected{% endif %}>{{ p.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="showNewPartner()">+ Новый</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Материал <span class="required">*</span></label>
|
||||
<select name="material_id" class="form-input" required id="material_sel">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in materials %}<option value="{{ m.id }}" data-price="{{ m.price }}" data-unit="{{ m.unit }}" {% if order and order.material_id==m.id %}selected{% endif %}>{{ m.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Количество <span class="required">*</span></label>
|
||||
<input type="number" name="quantity" class="form-input" required step="0.01" min="0.01" id="qty_in" value="{{ order.quantity if order else 1 }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Скидка, %</label>
|
||||
<input type="number" name="discount" class="form-input" step="0.1" min="0" max="100" id="disc_in" value="{{ order.discount if order else 0 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Быстрое добавление партнёра -->
|
||||
<div id="new_partner_block" style="display:none;margin-top:1rem;padding:1rem;border:1.5px dashed var(--border);border-radius:8px;background:var(--light-bg)">
|
||||
<div style="font-size:0.72rem;font-weight:700;color:var(--text-mid);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:0.8rem">Новый партнёр</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label class="form-label">Название *</label><input type="text" id="np_name" class="form-input" placeholder="ООО «Компания»"></div>
|
||||
<div class="form-group"><label class="form-label">Телефон</label><input type="text" id="np_phone" class="form-input"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input type="text" id="np_email" class="form-input"></div>
|
||||
<div class="form-group"><label class="form-label">Адрес</label><input type="text" id="np_address" class="form-input"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.8rem">
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="saveNewPartner()">✓ Сохранить</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="hideNewPartner()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="calc-preview">
|
||||
<div class="calc-title">📊 Расчёт стоимости (расходная)</div>
|
||||
<div class="calc-row"><span>Стоимость товара:</span><strong id="r_goods">0.00 ₽</strong></div>
|
||||
<div class="calc-row"><span>Скидка:</span><strong id="r_disc" style="color:var(--error)">−0.00 ₽</strong></div>
|
||||
<div class="calc-row total-row"><span>ИТОГО:</span><strong id="r_total">0.00 ₽</strong></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('expense_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function calc(){
|
||||
var mopt = document.getElementById('material_sel').options[document.getElementById('material_sel').selectedIndex];
|
||||
var price = parseFloat(mopt.dataset.price) || 0;
|
||||
var qty = parseFloat(document.getElementById('qty_in').value) || 0;
|
||||
var disc = parseFloat(document.getElementById('disc_in').value) || 0;
|
||||
var goods = price * qty;
|
||||
var dsum = goods * (disc/100);
|
||||
var total = Math.max(goods - dsum, 0);
|
||||
document.getElementById('r_goods').textContent = goods.toFixed(2) + ' ₽';
|
||||
document.getElementById('r_disc').textContent = '−' + dsum.toFixed(2) + ' ₽';
|
||||
document.getElementById('r_total').textContent = total.toFixed(2) + ' ₽';
|
||||
}
|
||||
document.getElementById('material_sel').addEventListener('change', calc);
|
||||
document.getElementById('qty_in').addEventListener('input', calc);
|
||||
document.getElementById('disc_in').addEventListener('input', calc);
|
||||
calc();
|
||||
function showNewPartner(){ document.getElementById('new_partner_block').style.display='block'; }
|
||||
function hideNewPartner(){ document.getElementById('new_partner_block').style.display='none'; }
|
||||
function saveNewPartner(){
|
||||
var name = document.getElementById('np_name').value.trim();
|
||||
if(!name){ alert('Введите название'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('phone', document.getElementById('np_phone').value);
|
||||
fd.append('email', document.getElementById('np_email').value);
|
||||
fd.append('address', document.getElementById('np_address').value);
|
||||
fetch('/partners/add?ajax=1',{method:'POST',body:fd})
|
||||
.then(r=>r.json()).then(res=>{
|
||||
var sel = document.getElementById('partner_sel');
|
||||
sel.add(new Option(res.name, res.id, true, true));
|
||||
hideNewPartner();
|
||||
document.getElementById('np_name').value='';
|
||||
document.getElementById('np_phone').value='';
|
||||
document.getElementById('np_email').value='';
|
||||
document.getElementById('np_address').value='';
|
||||
}).catch(()=>alert('Ошибка'));
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
38
templates/expense_list.html
Normal file
38
templates/expense_list.html
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Расходные накладные — Стройка{% endblock %}
|
||||
{% block page_title %}Расходные накладные{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('expense_add') }}" class="btn btn-primary">+ Новая расходная</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="sort-bar">
|
||||
Сортировка:
|
||||
<a href="?sort=date_desc" class="btn btn-sm {% if sort=='date_desc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↓</a>
|
||||
<a href="?sort=date_asc" class="btn btn-sm {% if sort=='date_asc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↑</a>
|
||||
<a href="?sort=price_desc" class="btn btn-sm {% if sort=='price_desc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↓</a>
|
||||
<a href="?sort=price_asc" class="btn btn-sm {% if sort=='price_asc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↑</a>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Номер</th><th>Дата</th><th>Тип материала</th><th>Партнёр</th><th>Материал</th><th>Кол-во</th><th>Стоимость, ₽</th><th>Скидка %</th><th>Скидка, ₽</th><th class="highlight-col">ИТОГО, ₽</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr>
|
||||
<td><strong>{{ o.number }}</strong></td>
|
||||
<td>{{ o.order_date }}</td>
|
||||
<td><span class="badge">{{ o.type_name }}</span></td>
|
||||
<td>{{ o.partner_name }}</td>
|
||||
<td>{{ o.material_name }}</td>
|
||||
<td>{{ o.quantity }} {{ o.unit }}</td>
|
||||
<td>{{ "%.2f"|format(o.goods) }}</td>
|
||||
<td>{% if o.discount > 0 %}{{ o.discount }}%{% else %}—{% endif %}</td>
|
||||
<td>{% if o.discount_sum > 0 %}−{{ "%.2f"|format(o.discount_sum) }}{% else %}—{% endif %}</td>
|
||||
<td class="highlight-col"><strong>{{ "%.2f"|format(o.total) }}</strong></td>
|
||||
<td><a href="{{ url_for('expense_edit', oid=o.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="11" style="text-align:center;padding:2rem;color:#aaa">Накладных пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-meta">Всего: <strong>{{ orders|length }}</strong></div>
|
||||
{% endblock %}
|
||||
69
templates/income_form.html
Normal file
69
templates/income_form.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новая приходная{% else %}Редактировать{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новая приходная накладная{% else %}Редактировать приходную #{{ order.id }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('income_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Номер накладной <span class="required">*</span></label>
|
||||
<input type="text" name="number" class="form-input" required value="{{ order.number if order else '' }}" placeholder="ПН-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Дата <span class="required">*</span></label>
|
||||
<input type="date" name="order_date" class="form-input" required value="{{ today }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Поставщик <span class="required">*</span></label>
|
||||
<select name="supplier_id" class="form-input" required id="supplier_sel">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for s in suppliers %}<option value="{{ s.id }}" data-dist="{{ s.distance }}" data-rate="{{ s.transport_rate }}" {% if order and order.supplier_id==s.id %}selected{% endif %}>{{ s.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Материал <span class="required">*</span></label>
|
||||
<select name="material_id" class="form-input" required id="material_sel">
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in materials %}<option value="{{ m.id }}" data-price="{{ m.price }}" data-weight="{{ m.weight }}" data-unit="{{ m.unit }}" {% if order and order.material_id==m.id %}selected{% endif %}>{{ m.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Количество <span class="required">*</span></label>
|
||||
<input type="number" name="quantity" class="form-input" required step="0.01" min="0.01" id="qty_in" value="{{ order.quantity if order else 1 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="calc-preview">
|
||||
<div class="calc-title">📊 Расчёт стоимости (приходная)</div>
|
||||
<div class="calc-row"><span>Стоимость товара:</span><strong id="r_goods">0.00 ₽</strong></div>
|
||||
<div class="calc-row"><span>Транспортные расходы:</span><strong id="r_transport">0.00 ₽</strong></div>
|
||||
<div class="calc-row total-row"><span>ИТОГО:</span><strong id="r_total">0.00 ₽</strong></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('income_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
function calc(){
|
||||
var mopt = document.getElementById('material_sel').options[document.getElementById('material_sel').selectedIndex];
|
||||
var sopt = document.getElementById('supplier_sel').options[document.getElementById('supplier_sel').selectedIndex];
|
||||
var price = parseFloat(mopt.dataset.price) || 0;
|
||||
var weight = parseFloat(mopt.dataset.weight) || 0;
|
||||
var dist = parseFloat(sopt.dataset.dist) || 0;
|
||||
var rate = parseFloat(sopt.dataset.rate) || 0;
|
||||
var qty = parseFloat(document.getElementById('qty_in').value) || 0;
|
||||
var goods = price * qty;
|
||||
var transp = weight * qty * rate * dist;
|
||||
var total = Math.max(goods + transp, 0);
|
||||
document.getElementById('r_goods').textContent = goods.toFixed(2) + ' ₽';
|
||||
document.getElementById('r_transport').textContent = transp.toFixed(2) + ' ₽';
|
||||
document.getElementById('r_total').textContent = total.toFixed(2) + ' ₽';
|
||||
}
|
||||
document.getElementById('material_sel').addEventListener('change', calc);
|
||||
document.getElementById('supplier_sel').addEventListener('change', calc);
|
||||
document.getElementById('qty_in').addEventListener('input', calc);
|
||||
calc();
|
||||
</script>
|
||||
{% endblock %}
|
||||
36
templates/income_list.html
Normal file
36
templates/income_list.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Приходные накладные — Стройка{% endblock %}
|
||||
{% block page_title %}Приходные накладные{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('income_add') }}" class="btn btn-primary">+ Новая приходная</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="sort-bar">
|
||||
Сортировка:
|
||||
<a href="?sort=date_desc" class="btn btn-sm {% if sort=='date_desc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↓</a>
|
||||
<a href="?sort=date_asc" class="btn btn-sm {% if sort=='date_asc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↑</a>
|
||||
<a href="?sort=price_desc" class="btn btn-sm {% if sort=='price_desc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↓</a>
|
||||
<a href="?sort=price_asc" class="btn btn-sm {% if sort=='price_asc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↑</a>
|
||||
</div>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Номер</th><th>Дата</th><th>Поставщик</th><th>Материал</th><th>Кол-во</th><th>Стоимость товара, ₽</th><th>Транспорт, ₽</th><th class="highlight-col">ИТОГО, ₽</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr>
|
||||
<td><strong>{{ o.number }}</strong></td>
|
||||
<td>{{ o.order_date }}</td>
|
||||
<td>{{ o.supplier_name }}</td>
|
||||
<td>{{ o.material_name }}</td>
|
||||
<td>{{ o.quantity }} {{ o.unit }}</td>
|
||||
<td>{{ "%.2f"|format(o.goods) }}</td>
|
||||
<td>{{ "%.2f"|format(o.transport) }}</td>
|
||||
<td class="highlight-col"><strong>{{ "%.2f"|format(o.total) }}</strong></td>
|
||||
<td><a href="{{ url_for('income_edit', oid=o.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" style="text-align:center;padding:2rem;color:#aaa">Накладных пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-meta">Всего: <strong>{{ orders|length }}</strong></div>
|
||||
{% endblock %}
|
||||
44
templates/index.html
Normal file
44
templates/index.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Материалы — Стройка{% endblock %}
|
||||
{% block page_title %}Склад материалов{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('material_add') }}" class="btn btn-primary">+ Добавить материал</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="stats-bar">
|
||||
<div class="stat-card"><div class="stat-label">Материалов</div><div class="stat-value">{{ stat.materials }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Поставщиков</div><div class="stat-value">{{ stat.suppliers }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Партнёров</div><div class="stat-value">{{ stat.partners }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Заказов</div><div class="stat-value">{{ stat.orders }}</div></div>
|
||||
</div>
|
||||
<form class="filter-bar" method="get">
|
||||
<div class="filter-group"><label>Поиск</label><input type="text" name="search" value="{{ search }}" placeholder="Название…" class="form-input"></div>
|
||||
<div class="filter-group"><label>Тип</label>
|
||||
<select name="type_id" class="form-input">
|
||||
<option value="">Все типы</option>
|
||||
{% for t in types %}<option value="{{ t.id }}" {% if type_filter|string==t.id|string %}selected{% endif %}>{{ t.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group filter-btns">
|
||||
<button type="submit" class="btn btn-primary">Применить</button>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Сброс</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Наименование</th><th>Тип</th><th>Ед.</th><th>Цена, ₽</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in materials %}
|
||||
<tr>
|
||||
<td><strong>{{ m.name }}</strong></td>
|
||||
<td><span class="badge">{{ m.type_name }}</span></td>
|
||||
<td>{{ m.unit }}</td>
|
||||
<td>{{ "%.2f"|format(m.current_price) }}</td>
|
||||
<td><a href="{{ url_for('material_edit', mid=m.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" style="text-align:center;padding:2rem;color:#aaa">Материалы не найдены</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-meta">Найдено: <strong>{{ materials|length }}</strong> материалов</div>
|
||||
{% endblock %}
|
||||
39
templates/material_form.html
Normal file
39
templates/material_form.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новый материал{% else %}Редактировать{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новый материал{% else %}Редактировать: {{ material.name }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('index') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Наименование <span class="required">*</span></label>
|
||||
<input type="text" name="name" class="form-input" required value="{{ material.name if material else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Тип материала <span class="required">*</span></label>
|
||||
<select name="material_type_id" class="form-input" required>
|
||||
<option value="">— выбрать —</option>
|
||||
{% for t in types %}<option value="{{ t.id }}" {% if material and material.material_type_id==t.id %}selected{% endif %}>{{ t.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Единица измерения</label>
|
||||
<select name="unit" class="form-input">
|
||||
{% for u in ['шт','м²','м³','м.п.','кг','т','мешок','рулон','лист'] %}
|
||||
<option value="{{ u }}" {% if material and material.unit==u %}selected{% endif %}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Цена, ₽ <span class="required">*</span></label>
|
||||
<input type="number" name="current_price" class="form-input" required step="0.01" min="0" value="{{ material.current_price if material else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
151
templates/order_form.html
Normal file
151
templates/order_form.html
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новый заказ{% else %}Редактировать заказ{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новый заказ{% else %}Редактировать заказ {{ order.number }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('orders_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card" style="max-width:1000px">
|
||||
<form method="post" id="orderForm">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Номер заказа <span class="required">*</span></label>
|
||||
<input type="text" name="number" class="form-input" required value="{{ order.number if order else '' }}" placeholder="ЗК-001">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Дата заказа <span class="required">*</span></label>
|
||||
<input type="date" name="order_date" class="form-input" required value="{{ today }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Дата доставки</label>
|
||||
<input type="date" name="delivery_date" class="form-input" value="{{ order.delivery_date if order else '' }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Статус</label>
|
||||
<select name="status" class="form-input">
|
||||
{% for s in ['Новый','В обработке','Выполнен','Отменён'] %}
|
||||
<option value="{{ s }}" {% if order and order.status==s %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Контрагент <span class="required">*</span></label>
|
||||
<select name="counterparty_id" class="form-input" required>
|
||||
<option value="">— выбрать —</option>
|
||||
<optgroup label="Поставщики (приходная)">
|
||||
{% for c in counterparties if c.type==2 %}
|
||||
<option value="{{ c.id }}" {% if order and order.counterparty_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
<optgroup label="Партнёры (расходная)">
|
||||
{% for c in counterparties if c.type==1 %}
|
||||
<option value="{{ c.id }}" {% if order and order.counterparty_id==c.id %}selected{% endif %}>{{ c.name }}</option>
|
||||
{% endfor %}
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Позиции заказа -->
|
||||
<div style="margin-top:1.5rem">
|
||||
<div style="font-size:0.75rem;font-weight:700;color:var(--text-mid);text-transform:uppercase;letter-spacing:0.07em;margin-bottom:0.8rem">
|
||||
Позиции заказа
|
||||
</div>
|
||||
<div class="table-wrapper" style="margin-bottom:1rem">
|
||||
<table class="data-table" id="itemsTable">
|
||||
<thead><tr><th>Материал</th><th>Ед.</th><th>Количество</th><th>Цена, ₽</th><th class="highlight-col">Сумма, ₽</th><th></th></tr></thead>
|
||||
<tbody id="itemsBody">
|
||||
{% for item in items %}
|
||||
<tr class="item-row">
|
||||
<td>
|
||||
<select name="material_id[]" class="form-input material-select" required>
|
||||
<option value="">— выбрать —</option>
|
||||
{% for m in materials %}<option value="{{ m.id }}" data-price="{{ m.current_price }}" data-unit="{{ m.unit }}" {% if item.material_id==m.id %}selected{% endif %}>{{ m.name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="unit-cell">{{ item.unit if item else '—' }}</td>
|
||||
<td><input type="number" name="quantity[]" class="form-input qty-input" step="0.01" min="0.01" required value="{{ item.quantity }}"></td>
|
||||
<td><input type="number" name="unit_price[]" class="form-input price-input" step="0.01" min="0" required value="{{ item.unit_price }}"></td>
|
||||
<td class="highlight-col line-total"><strong>{{ "%.2f"|format(item.quantity * item.unit_price) }} ₽</strong></td>
|
||||
<td><button type="button" class="btn btn-sm btn-danger" onclick="removeRow(this)">✕</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="addRow()">+ Добавить позицию</button>
|
||||
</div>
|
||||
|
||||
<!-- Итого -->
|
||||
<div class="calc-preview" style="margin-top:1rem">
|
||||
<div class="calc-title">📊 Итого по заказу</div>
|
||||
<div class="calc-row total-row"><span>ИТОГО:</span><strong id="grandTotal">0.00 ₽</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить заказ</button>
|
||||
<a href="{{ url_for('orders_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var materialsData = {
|
||||
{% for m in materials %}
|
||||
{{ m.id }}: { price: {{ m.current_price }}, unit: '{{ m.unit }}', name: '{{ m.name }}' },
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function addRow() {
|
||||
var tbody = document.getElementById('itemsBody');
|
||||
var row = document.createElement('tr');
|
||||
row.className = 'item-row';
|
||||
row.innerHTML = '<td><select name="material_id[]" class="form-input material-select" required><option value="">— выбрать —</option>' +
|
||||
Object.entries(materialsData).map(([id,m]) => '<option value="'+id+'" data-price="'+m.price+'" data-unit="'+m.unit+'">'+m.name+'</option>').join('') +
|
||||
'</select></td>' +
|
||||
'<td class="unit-cell">—</td>' +
|
||||
'<td><input type="number" name="quantity[]" class="form-input qty-input" step="0.01" min="0.01" required value="1"></td>' +
|
||||
'<td><input type="number" name="unit_price[]" class="form-input price-input" step="0.01" min="0" required value="0"></td>' +
|
||||
'<td class="highlight-col line-total"><strong>0.00 ₽</strong></td>' +
|
||||
'<td><button type="button" class="btn btn-sm btn-danger" onclick="removeRow(this)">✕</button></td>';
|
||||
tbody.appendChild(row);
|
||||
bindRow(row);
|
||||
}
|
||||
|
||||
function removeRow(btn) {
|
||||
btn.closest('tr').remove();
|
||||
updateTotal();
|
||||
}
|
||||
|
||||
function bindRow(row) {
|
||||
var sel = row.querySelector('.material-select');
|
||||
var qty = row.querySelector('.qty-input');
|
||||
var price = row.querySelector('.price-input');
|
||||
sel.addEventListener('change', function() {
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
price.value = opt.dataset.price || 0;
|
||||
row.querySelector('.unit-cell').textContent = opt.dataset.unit || '—';
|
||||
updateLine(row); updateTotal();
|
||||
});
|
||||
qty.addEventListener('input', function() { updateLine(row); updateTotal(); });
|
||||
price.addEventListener('input', function() { updateLine(row); updateTotal(); });
|
||||
}
|
||||
|
||||
function updateLine(row) {
|
||||
var q = parseFloat(row.querySelector('.qty-input').value) || 0;
|
||||
var p = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
row.querySelector('.line-total').innerHTML = '<strong>' + (q*p).toFixed(2) + ' ₽</strong>';
|
||||
}
|
||||
|
||||
function updateTotal() {
|
||||
var total = 0;
|
||||
document.querySelectorAll('.item-row').forEach(function(row) {
|
||||
var q = parseFloat(row.querySelector('.qty-input').value) || 0;
|
||||
var p = parseFloat(row.querySelector('.price-input').value) || 0;
|
||||
total += q * p;
|
||||
});
|
||||
document.getElementById('grandTotal').textContent = total.toFixed(2) + ' ₽';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.item-row').forEach(bindRow);
|
||||
updateTotal();
|
||||
</script>
|
||||
{% endblock %}
|
||||
59
templates/orders.html
Normal file
59
templates/orders.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Заявки — Стройка{% endblock %}
|
||||
{% block page_title %}Заявки на поставку{% endblock %}
|
||||
{% block topbar_actions %}
|
||||
<a href="{{ url_for('order_add') }}" class="btn btn-primary">+ Новая заявка</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<!-- Сортировка -->
|
||||
<div class="sort-bar">
|
||||
<span>Сортировка:</span>
|
||||
<a href="?sort=date_desc" class="btn btn-sm {% if sort == 'date_desc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↓</a>
|
||||
<a href="?sort=date_asc" class="btn btn-sm {% if sort == 'date_asc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↑</a>
|
||||
<a href="?sort=price_desc" class="btn btn-sm {% if sort == 'price_desc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Цена ↓</a>
|
||||
<a href="?sort=price_asc" class="btn btn-sm {% if sort == 'price_asc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Цена ↑</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Дата</th>
|
||||
<th>Партнёр</th>
|
||||
<th>Материал</th>
|
||||
<th>Кол-во</th>
|
||||
<th>Стоимость товара, ₽</th>
|
||||
<th>Транспорт, ₽</th>
|
||||
<th>Скидка, %</th>
|
||||
<th>Скидка, ₽</th>
|
||||
<th class="highlight-col">ИТОГО, ₽</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr>
|
||||
<td style="color:var(--text-light)">{{ o.id }}</td>
|
||||
<td>{{ o.order_date }}</td>
|
||||
<td><strong>{{ o.partner_name }}</strong></td>
|
||||
<td>{{ o.material_name }}</td>
|
||||
<td>{{ o.quantity }} {{ o.unit }}</td>
|
||||
<td>{{ "%.2f"|format(o.goods) }}</td>
|
||||
<td>{{ "%.2f"|format(o.transport) }}</td>
|
||||
<td>{% if o.discount > 0 %}<span class="badge">{{ o.discount }}%</span>{% else %}—{% endif %}</td>
|
||||
<td>{% if o.discount_sum > 0 %}−{{ "%.2f"|format(o.discount_sum) }}{% else %}—{% endif %}</td>
|
||||
<td class="highlight-col"><strong>{{ "%.2f"|format(o.total) }}</strong></td>
|
||||
<td><a href="{{ url_for('order_edit', oid=o.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="11" style="text-align:center;padding:2rem;color:#aaa">Заявок пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-meta">Всего заявок: <strong>{{ orders|length }}</strong></div>
|
||||
|
||||
{% endblock %}
|
||||
70
templates/orders_list.html
Normal file
70
templates/orders_list.html
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Заказы — Стройка{% endblock %}
|
||||
{% block page_title %}Заказы{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('order_add') }}" class="btn btn-primary">+ Новый заказ</a>{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<!-- Поиск -->
|
||||
<form class="filter-bar" method="get" action="{{ url_for('orders_list') }}">
|
||||
<div class="filter-group" style="flex: 2;">
|
||||
<label>🔍 Поиск</label>
|
||||
<input type="text" name="search" value="{{ search or '' }}" placeholder="Номер заказа, контрагент, статус..." class="form-input" style="min-width: 250px;">
|
||||
</div>
|
||||
<div class="filter-group filter-btns" style="flex-direction: row; align-items: flex-end;">
|
||||
<button type="submit" class="btn btn-primary">Найти</button>
|
||||
{% if search %}
|
||||
<a href="{{ url_for('orders_list', sort=sort) }}" class="btn btn-secondary">Сброс</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<input type="hidden" name="sort" value="{{ sort or 'date_desc' }}">
|
||||
</form>
|
||||
|
||||
<div class="sort-bar">
|
||||
Сортировка:
|
||||
<a href="?sort=date_desc{% if search %}&search={{ search }}{% endif %}" class="btn btn-sm {% if sort=='date_desc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↓</a>
|
||||
<a href="?sort=date_asc{% if search %}&search={{ search }}{% endif %}" class="btn btn-sm {% if sort=='date_asc' %}btn-primary{% else %}btn-secondary{% endif %}">📅 Дата ↑</a>
|
||||
<a href="?sort=price_desc{% if search %}&search={{ search }}{% endif %}" class="btn btn-sm {% if sort=='price_desc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↓</a>
|
||||
<a href="?sort=price_asc{% if search %}&search={{ search }}{% endif %}" class="btn btn-sm {% if sort=='price_asc' %}btn-primary{% else %}btn-secondary{% endif %}">💰 Сумма ↑</a>
|
||||
</div>
|
||||
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Номер</th>
|
||||
<th>Дата</th>
|
||||
<th>Дата доставки</th>
|
||||
<th>Контрагент</th>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Позиций</th>
|
||||
<th class="highlight-col">ИТОГО, ₽</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for o in orders %}
|
||||
<tr>
|
||||
<td><strong>{{ o.number }}</strong></td>
|
||||
<td>{{ o.order_date }}</td>
|
||||
<td>{{ o.delivery_date or '—' }}</td>
|
||||
<td>{{ o.counterparty_name }}</td>
|
||||
<td><span class="badge">{% if o.counterparty_type==2 %}Приходная{% else %}Расходная{% endif %}</span></td>
|
||||
<td><span class="badge">{{ o.status }}</span></td>
|
||||
<td>{{ o.items_count }}</td>
|
||||
<td class="highlight-col"><strong>{{ "%.2f"|format(o.total) }}</strong></td>
|
||||
<td><a href="{{ url_for('order_edit', oid=o.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" style="text-align:center;padding:2rem;color:#aaa">Заказов пока нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-meta">
|
||||
Всего: <strong>{{ orders|length }}</strong> заказов
|
||||
{% if search %}
|
||||
<span style="margin-left: 1rem;">🔍 Результаты поиска: «{{ search }}»</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
20
templates/partner_form.html
Normal file
20
templates/partner_form.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новый партнёр{% else %}Редактировать партнёра{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новый партнёр{% else %}Редактировать: {{ partner.name }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('partners_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width"><label class="form-label">Название <span class="required">*</span></label><input type="text" name="name" class="form-input" required value="{{ partner.name if partner else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Телефон</label><input type="text" name="phone" class="form-input" value="{{ partner.phone if partner else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input type="email" name="email" class="form-input" value="{{ partner.email if partner else '' }}"></div>
|
||||
<div class="form-group full-width"><label class="form-label">Адрес</label><textarea name="address" class="form-input form-textarea">{{ partner.address if partner else '' }}</textarea></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('partners_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
26
templates/partners.html
Normal file
26
templates/partners.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Партнёры — Стройка{% endblock %}
|
||||
{% block page_title %}Партнёры{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('partner_add') }}" class="btn btn-primary">+ Добавить партнёра</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Название</th><th>Телефон</th><th>Email</th><th>Адрес</th><th>Накладных</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in partners %}
|
||||
<tr>
|
||||
<td style="color:var(--text-light)">{{ p.id }}</td>
|
||||
<td><strong>{{ p.name }}</strong></td>
|
||||
<td>{{ p.phone or '—' }}</td>
|
||||
<td>{{ p.email or '—' }}</td>
|
||||
<td>{{ p.address or '—' }}</td>
|
||||
<td><span class="badge">{{ p.order_count }}</span></td>
|
||||
<td><a href="{{ url_for('partner_edit', pid=p.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" style="text-align:center;padding:2rem;color:#aaa">Партнёров нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
templates/supplier_form.html
Normal file
25
templates/supplier_form.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{% if action=='add' %}Новый поставщик{% else %}Редактировать поставщика{% endif %} — Стройка{% endblock %}
|
||||
{% block page_title %}{% if action=='add' %}Новый поставщик{% else %}Редактировать: {{ supplier.name }}{% endif %}{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('suppliers_list') }}" class="btn btn-secondary">← Назад</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-card">
|
||||
<form method="post">
|
||||
<div class="form-grid">
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">Название <span class="required">*</span></label>
|
||||
<input type="text" name="name" class="form-input" required value="{{ supplier.name if supplier else '' }}">
|
||||
</div>
|
||||
<div class="form-group"><label class="form-label">Телефон</label><input type="text" name="phone" class="form-input" value="{{ supplier.phone if supplier else '' }}"></div>
|
||||
<div class="form-group"><label class="form-label">Email</label><input type="email" name="email" class="form-input" value="{{ supplier.email if supplier else '' }}"></div>
|
||||
<div class="form-group full-width"><label class="form-label">Адрес</label><textarea name="address" class="form-input form-textarea">{{ supplier.address if supplier else '' }}</textarea></div>
|
||||
<div class="form-group"><label class="form-label">Расстояние, км</label><input type="number" name="distance" class="form-input" step="0.1" min="0" value="{{ supplier.distance if supplier else 0 }}"></div>
|
||||
<div class="form-group"><label class="form-label">Тариф доставки, ₽/кг/км</label><input type="number" name="transport_rate" class="form-input" step="0.01" min="0" value="{{ supplier.transport_rate if supplier else 0 }}"></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">💾 Сохранить</button>
|
||||
<a href="{{ url_for('suppliers_list') }}" class="btn btn-secondary">Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
28
templates/suppliers.html
Normal file
28
templates/suppliers.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Поставщики — Стройка{% endblock %}
|
||||
{% block page_title %}Поставщики{% endblock %}
|
||||
{% block topbar_actions %}<a href="{{ url_for('supplier_add') }}" class="btn btn-primary">+ Добавить поставщика</a>{% endblock %}
|
||||
{% block content %}
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>#</th><th>Название</th><th>Телефон</th><th>Email</th><th>Адрес</th><th>Расст., км</th><th>Тариф р/кг/км</th><th>Накладных</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in suppliers %}
|
||||
<tr>
|
||||
<td style="color:var(--text-light)">{{ s.id }}</td>
|
||||
<td><strong>{{ s.name }}</strong></td>
|
||||
<td>{{ s.phone or '—' }}</td>
|
||||
<td>{{ s.email or '—' }}</td>
|
||||
<td>{{ s.address or '—' }}</td>
|
||||
<td>{{ s.distance }}</td>
|
||||
<td>{{ s.transport_rate }}</td>
|
||||
<td><span class="badge">{{ s.order_count }}</span></td>
|
||||
<td><a href="{{ url_for('supplier_edit', sid=s.id) }}" class="btn btn-sm btn-edit">✏️ Изменить</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="9" style="text-align:center;padding:2rem;color:#aaa">Поставщиков нет</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loading…
Reference in a new issue