23.03.2026

This commit is contained in:
user_2i 2026-03-23 11:35:48 +05:00
parent 2ae3ab26ed
commit 2c7297ef31
38 changed files with 6896 additions and 93 deletions

BIN
Categories_import.xlsx Normal file

Binary file not shown.

BIN
Clients_import.xlsx Normal file

Binary file not shown.

BIN
Materials_import.xlsx Normal file

Binary file not shown.

BIN
OrderItems_import.xlsx Normal file

Binary file not shown.

BIN
Orders_import.xlsx Normal file

Binary file not shown.

BIN
Products_import.xlsx Normal file

Binary file not shown.

View file

@ -1,93 +0,0 @@
# MY PROJECT
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://192.168.0.185/Danil/my-project.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://192.168.0.185/Danil/my-project/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
Resource/OfficeStyle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

BIN
Resource/Orders_import.xlsx Normal file

Binary file not shown.

Binary file not shown.

BIN
Resource/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

653
main.py Normal file
View file

@ -0,0 +1,653 @@
import sqlite3
import pandas as pd
import os
import sys
from datetime import datetime
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
RESOURCE_DIR = os.path.join(BASE_DIR, "Resource")
DB_PATH = os.path.join(BASE_DIR, "partners.db")
def create_tables(db_connection):
"""Создает все необходимые таблицы в базе данных"""
cursor = db_connection.cursor()
cursor.executescript('''
-- Типы контрагентов
CREATE TABLE IF NOT EXISTS ContractorType (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type_name VARCHAR(100) NOT NULL UNIQUE
);
-- Контрагенты/Клиенты
CREATE TABLE IF NOT EXISTS Clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contractor_type_id INTEGER REFERENCES ContractorType(id),
name VARCHAR(255) NOT NULL,
director VARCHAR(255),
email VARCHAR(255),
phone VARCHAR(50),
address TEXT,
inn VARCHAR(20) UNIQUE,
rating DECIMAL(3,2) DEFAULT 0.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Категории продукции
CREATE TABLE IF NOT EXISTS Categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_name VARCHAR(100) NOT NULL UNIQUE,
coefficient DECIMAL(5,2) NOT NULL DEFAULT 1.0
);
-- Таблица материалов
CREATE TABLE IF NOT EXISTS Materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
material_name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
category VARCHAR(50),
color VARCHAR(50),
density DECIMAL(10,2),
thickness DECIMAL(10,2),
supplier VARCHAR(255),
country VARCHAR(100),
wear_class VARCHAR(20),
price_per_unit DECIMAL(15,2),
unit VARCHAR(20) DEFAULT 'шт',
in_stock INTEGER DEFAULT 0,
production_time_days INTEGER DEFAULT 1,
daily_capacity INTEGER DEFAULT 10,
cost_price DECIMAL(15,2),
markup_percent DECIMAL(5,2) DEFAULT 30,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Каталог продукции
CREATE TABLE IF NOT EXISTS Catalog (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER REFERENCES Categories(id),
material_id INTEGER REFERENCES Materials(id),
product_name VARCHAR(255) NOT NULL,
article VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
min_price DECIMAL(15,2) NOT NULL,
quantity_in_stock INTEGER DEFAULT 0,
production_time_days INTEGER DEFAULT 1,
daily_capacity INTEGER DEFAULT 10,
cost_price DECIMAL(15,2),
profit_per_unit DECIMAL(15,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Таблица корзин
CREATE TABLE IF NOT EXISTS Cart (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER REFERENCES Clients(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(50) DEFAULT 'активна'
);
-- Таблица товаров в корзине
CREATE TABLE IF NOT EXISTS CartItems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cart_id INTEGER REFERENCES Cart(id),
product_id INTEGER REFERENCES Catalog(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_moment DECIMAL(15,2) NOT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Заказы
CREATE TABLE IF NOT EXISTS Orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id INTEGER REFERENCES Clients(id),
order_date DATE NOT NULL,
total_amount DECIMAL(15,2) NOT NULL,
status VARCHAR(50) DEFAULT 'Новый',
production_days INTEGER DEFAULT 1,
production_start_date DATE,
production_end_date DATE,
production_status VARCHAR(50) DEFAULT 'Ожидает',
assembly_percent INTEGER DEFAULT 15,
delivery_percent INTEGER DEFAULT 10,
assembly_cost DECIMAL(15,2) DEFAULT 0,
delivery_cost DECIMAL(15,2) DEFAULT 0,
base_amount DECIMAL(15,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Состав заказа
CREATE TABLE IF NOT EXISTS OrderItems (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL REFERENCES Orders(id),
product_id INTEGER NOT NULL REFERENCES Catalog(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_moment DECIMAL(15,2) NOT NULL
);
-- Индексы для оптимизации
CREATE INDEX IF NOT EXISTS idx_client_type ON Clients(contractor_type_id);
CREATE INDEX IF NOT EXISTS idx_client_inn ON Clients(inn);
CREATE INDEX IF NOT EXISTS idx_catalog_category ON Catalog(category_id);
CREATE INDEX IF NOT EXISTS idx_catalog_material ON Catalog(material_id);
CREATE INDEX IF NOT EXISTS idx_catalog_article ON Catalog(article);
CREATE INDEX IF NOT EXISTS idx_material_name ON Materials(material_name);
CREATE INDEX IF NOT EXISTS idx_material_category ON Materials(category);
CREATE INDEX IF NOT EXISTS idx_material_supplier ON Materials(supplier);
CREATE INDEX IF NOT EXISTS idx_cart_client ON Cart(client_id);
CREATE INDEX IF NOT EXISTS idx_cart_status ON Cart(status);
CREATE INDEX IF NOT EXISTS idx_cart_items_cart ON CartItems(cart_id);
CREATE INDEX IF NOT EXISTS idx_order_client ON Orders(client_id);
CREATE INDEX IF NOT EXISTS idx_order_date ON Orders(order_date);
CREATE INDEX IF NOT EXISTS idx_order_status ON Orders(status);
CREATE INDEX IF NOT EXISTS idx_production_status ON Orders(production_status);
CREATE INDEX IF NOT EXISTS idx_orderitems_order ON OrderItems(order_id);
CREATE INDEX IF NOT EXISTS idx_orderitems_product ON OrderItems(product_id);
''')
db_connection.commit()
print("✅ Таблицы успешно созданы")
def calculate_discount(total_quantity):
"""Рассчитывает скидку на основе общего количества заказанной продукции"""
if total_quantity >= 300000:
return 15, '15% (более 300000 ед.)'
elif total_quantity >= 50000:
return 10, '10% (50000-300000 ед.)'
elif total_quantity >= 10000:
return 5, '5% (10000-50000 ед.)'
else:
return 0, '0% (до 10000 ед.)'
def get_clients(db_path=DB_PATH, sort_by='rating', ascending=False):
"""Получает данные о клиентах с возможностью сортировки"""
try:
conn = sqlite3.connect(db_path)
query = """
SELECT
ct.type_name AS "Тип клиента",
c.name AS "Наименование клиента",
c.director AS "Директор",
c.email AS "Электронная почта",
c.phone AS "Телефон",
c.address AS "Адрес",
c.inn AS "ИНН",
c.rating AS "Рейтинг",
c.created_at AS "Дата создания",
COALESCE(SUM(oi.quantity), 0) as total_quantity,
COALESCE(SUM(o.total_amount), 0) as total_amount
FROM Clients c
JOIN ContractorType ct ON c.contractor_type_id = ct.id
LEFT JOIN Orders o ON c.id = o.client_id
LEFT JOIN OrderItems oi ON o.id = oi.order_id
GROUP BY c.id
"""
if sort_by == 'discount':
query += " ORDER BY total_quantity"
elif sort_by == 'rating':
query += " ORDER BY c.rating"
elif sort_by == 'name':
query += " ORDER BY c.name"
elif sort_by == 'type':
query += " ORDER BY ct.type_name, c.name"
else:
query += " ORDER BY c.rating"
query += " DESC" if not ascending else " ASC"
df = pd.read_sql_query(query, conn)
conn.close()
if not df.empty:
df['Скидка_процент'] = df['total_quantity'].apply(lambda x: calculate_discount(x)[0])
df['Скидка_текст'] = df['total_quantity'].apply(lambda x: calculate_discount(x)[1])
df['Общая_сумма'] = df['total_amount'].apply(lambda x: f"{x:,.2f} руб.")
return df
except Exception as e:
print(f"❌ Ошибка: {e}")
return None
def get_orders_history(db_path=DB_PATH, limit=50, sort_by='date_desc'):
"""Получает историю заказов с разными вариантами сортировки"""
try:
conn = sqlite3.connect(db_path)
query = """
SELECT
o.order_date AS "Дата заказа",
c.name AS "Клиент",
GROUP_CONCAT(cat.product_name || ' x' || oi.quantity, ', ') AS "Товары",
SUM(oi.quantity) AS "Количество",
o.total_amount AS "Сумма заказа",
o.status AS "Статус"
FROM Orders o
JOIN Clients c ON o.client_id = c.id
LEFT JOIN OrderItems oi ON o.id = oi.order_id
LEFT JOIN Catalog cat ON oi.product_id = cat.id
GROUP BY o.id
"""
if sort_by == 'date_desc':
query += " ORDER BY o.order_date DESC, o.created_at DESC"
elif sort_by == 'date_asc':
query += " ORDER BY o.order_date ASC, o.created_at ASC"
elif sort_by == 'quantity_desc':
query += " ORDER BY SUM(oi.quantity) DESC"
elif sort_by == 'amount_desc':
query += " ORDER BY o.total_amount DESC"
elif sort_by == 'status':
query += " ORDER BY o.status, o.order_date DESC"
else:
query += " ORDER BY o.order_date DESC, o.created_at DESC"
query += " LIMIT ?"
df = pd.read_sql_query(query, conn, params=(limit,))
conn.close()
return df
except Exception as e:
print(f"❌ Ошибка: {e}")
return None
def print_client_cards(clients_df):
"""Выводит карточки клиентов в красивом формате"""
if clients_df is None or clients_df.empty:
print("📭 Нет данных о клиентах")
return
for index, row in clients_df.iterrows():
print("╔" + "═" * 79 + "╗")
print(f"║ {'КАРТОЧКА КЛИЕНТА':^77} ║")
print("╠" + "═" * 79 + "╣")
type_text = f"Тип: {row['Тип клиента']}"
name_text = f"Наименование: {row['Наименование клиента']}"
director_text = f"Директор: {row['Директор']}"
rating_text = f"Рейтинг: {row['Рейтинг']}"
quantity_text = f"Заказано единиц: {row['total_quantity']:,}"
amount_text = f"На сумму: {row['total_amount']:,.2f} руб."
discount_text = f"Скидка: {row['Скидка_текст']}"
email_text = f"Email: {row['Электронная почта']}"
phone_text = f"Телефон: {row['Телефон']}"
inn_text = f"ИНН: {row['ИНН']}"
address = row['Адрес'] if row['Адрес'] else 'Не указан'
if len(address) > 70:
address = address[:67] + "..."
address_text = f"Адрес: {address}"
print(f"║ {type_text:<77} ║")
print(f"║ {name_text:<77} ║")
print(f"║ {director_text:<77} ║")
print(f"║ {rating_text:<77} ║")
print(f"║ {quantity_text:<77} ║")
print(f"║ {amount_text:<77} ║")
print(f"║ {discount_text:<77} ║")
print(f"║ {'':<77} ║")
print(f"║ {email_text:<77} ║")
print(f"║ {phone_text:<77} ║")
print(f"║ {inn_text:<77} ║")
print(f"║ {address_text:<77} ║")
print("╚" + "═" * 79 + "╝")
print()
def print_orders_cards(orders_df):
"""Выводит карточки заказов в виде таблицы"""
if orders_df is None or orders_df.empty:
print("📭 Нет данных о заказах")
return
print("╔" + "═" * 167 + "╗")
print(f"║ {'ИСТОРИЯ ЗАКАЗОВ':^165} ║")
print("╠" + "═" * 167 + "╣")
headers = ["Дата", "Клиент", "Товары", "Кол-во", "Статус", "Сумма"]
print("║ {:<12} {:<25} {:<55} {:>12} {:<15} {:>15} ║".format(
headers[0], headers[1], headers[2], headers[3], headers[4], headers[5]
))
print("╠" + "═" * 167 + "╣")
for index, row in orders_df.iterrows():
date = str(row['Дата заказа'])[:10]
client = str(row['Клиент'])[:25] if len(str(row['Клиент'])) > 25 else str(row['Клиент'])
products = str(row['Товары'])[:55] if len(str(row['Товары'])) > 55 else str(row['Товары'])
quantity = f"{int(row['Количество']):,}"
status = str(row['Статус'])[:15] if len(str(row['Статус'])) > 15 else str(row['Статус'])
amount = f"{float(row['Сумма заказа']):,.2f}"
line = f"║ {date:<12} {client:<25} {products:<55} {quantity:>12} {status:<15} {amount:>15} ║"
print(line)
print("╚" + "═" * 167 + "╝")
total_quantity = orders_df['Количество'].sum()
total_amount = orders_df['Сумма заказа'].sum()
print(f"📊 ИТОГО: {len(orders_df)} заказов, {total_quantity:,.0f} единиц, общая сумма: {total_amount:,.2f} руб.")
status_stats = orders_df['Статус'].value_counts()
print("📊 Статусы заказов:")
for status, count in status_stats.items():
print(f" - {status}: {count}")
def simple_choice(options):
"""Упрощенная функция выбора"""
print("\n" + "=" * 60)
print("ВЫБЕРИТЕ ОПЦИЮ:")
print("=" * 60)
for i, option in enumerate(options, 1):
print(f"{i}. {option}")
while True:
try:
choice = input(f"\nВведите номер (1-{len(options)}): ").strip()
if choice.isdigit():
choice_num = int(choice)
if 1 <= choice_num <= len(options):
return options[choice_num - 1]
print(f"❌ Пожалуйста, введите число от 1 до {len(options)}")
except KeyboardInterrupt:
print("\n👋 Программа прервана пользователем.")
sys.exit(0)
def safe_float(value, default=None):
"""Безопасное преобразование в float"""
if pd.isna(value) or value == '-':
return default
try:
return float(value)
except (ValueError, TypeError):
return default
def safe_int(value, default=None):
"""Безопасное преобразование в int"""
if pd.isna(value) or value == '-':
return default
try:
return int(float(value))
except (ValueError, TypeError):
return default
def load_data_from_files():
"""Загружает данные из Excel файлов в базу данных"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
print("\n📁 Введите имена файлов (или нажмите Enter для значений по умолчанию):")
clients_file = input("Файл с клиентами (Enter = Clients_import.xlsx): ").strip() or "Clients_import.xlsx"
categories_file = input("Файл с категориями (Enter = Categories_import.xlsx): ").strip() or "Categories_import.xlsx"
materials_file = input("Файл с материалами (Enter = Materials_import.xlsx): ").strip() or "Materials_import.xlsx"
catalog_file = input("Файл с каталогом (Enter = Products_import.xlsx): ").strip() or "Products_import.xlsx"
orders_file = input("Файл с заказами (Enter = Orders_import.xlsx): ").strip() or "Orders_import.xlsx"
orderitems_file = input("Файл с составом заказов (Enter = OrderItems_import.xlsx): ").strip() or "OrderItems_import.xlsx"
# Формируем полные пути
clients_file = os.path.join(RESOURCE_DIR, os.path.basename(clients_file))
categories_file = os.path.join(RESOURCE_DIR, os.path.basename(categories_file))
materials_file = os.path.join(RESOURCE_DIR, os.path.basename(materials_file))
catalog_file = os.path.join(RESOURCE_DIR, os.path.basename(catalog_file))
orders_file = os.path.join(RESOURCE_DIR, os.path.basename(orders_file))
orderitems_file = os.path.join(RESOURCE_DIR, os.path.basename(orderitems_file))
print(f"\n📂 Загружаем файлы из папки: {RESOURCE_DIR}")
# 1. Загрузка типов контрагентов
print("\n1. 📊 Загрузка типов контрагентов...")
clients_df = pd.read_excel(clients_file)
if 'Тип партнера' in clients_df.columns:
contractor_types = clients_df['Тип партнера'].unique()
for contractor_type in contractor_types:
cursor.execute("INSERT OR IGNORE INTO ContractorType (type_name) VALUES (?)", (contractor_type,))
print(f" Загружено {len(contractor_types)} типов")
# 2. Загрузка клиентов
print("2. 👥 Загрузка клиентов...")
loaded_clients = 0
for _, row in clients_df.iterrows():
type_value = row.get('Тип партнера', 'Клиент')
cursor.execute("SELECT id FROM ContractorType WHERE type_name = ?", (type_value,))
result = cursor.fetchone()
contractor_type_id = result[0] if result else 1
cursor.execute("""INSERT OR IGNORE INTO Clients
(contractor_type_id, name, director, email, phone, address, inn, rating)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(contractor_type_id,
row.get('Наименование партнера', ''),
row.get('Директор', ''),
row.get('Электронная почта партнера', ''),
row.get('Телефон партнера', ''),
row.get('Юридический адрес партнера', ''),
row.get('ИНН', ''),
safe_float(row.get('Рейтинг', 0.0), 0.0))
)
loaded_clients += 1
print(f" Загружено {loaded_clients} клиентов")
# 3. Загрузка категорий
print("3. 📦 Загрузка категорий...")
categories_df = pd.read_excel(categories_file)
loaded_categories = 0
for _, row in categories_df.iterrows():
cursor.execute("INSERT OR IGNORE INTO Categories (category_name, coefficient) VALUES (?, ?)",
(row.get('Тип продукции', ''),
safe_float(row.get('Коэффициент типа продукции', 1.0), 1.0))
)
loaded_categories += 1
print(f" Загружено {loaded_categories} категорий")
# 4. Загрузка материалов
print("4. 📦 Загрузка материалов...")
materials_df = pd.read_excel(materials_file)
loaded_materials = 0
for _, row in materials_df.iterrows():
cursor.execute("""
INSERT OR IGNORE INTO Materials
(material_name, description, category, color, density, thickness,
supplier, country, wear_class, price_per_unit, unit, in_stock,
production_time_days, daily_capacity, markup_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
row.get('Наименование материала', ''),
row.get('Описание', ''),
row.get('Категория', ''),
row.get('Цвет', ''),
safe_float(row.get('Плотность', None)),
safe_float(row.get('Толщина', None)),
row.get('Поставщик', ''),
row.get('Страна', ''),
row.get('Класс износостойкости', ''),
safe_float(row.get('Цена за ед.', 0), 0),
row.get('Единица', 'шт'),
safe_int(row.get('В наличии', 0), 0),
1, 10, 30
))
loaded_materials += 1
print(f" ✅ Загружено {loaded_materials} материалов")
# 5. Загрузка каталога
print("5. 🏷️ Загрузка каталога продукции...")
catalog_df = pd.read_excel(catalog_file)
loaded_products = 0
for index, row in catalog_df.iterrows():
prod_days = 1
if 'production_time_days' in catalog_df.columns:
val = row['production_time_days']
if not pd.isna(val):
try:
prod_days = int(float(val))
except:
prod_days = 1
cursor.execute("""
INSERT OR IGNORE INTO Catalog
(category_id, material_id, product_name, article, description, min_price, quantity_in_stock,
production_time_days, daily_capacity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
safe_int(row.get('category_id', 1), 1),
safe_int(row.get('material_id', None)),
row.get('product_name', ''),
row.get('article', ''),
row.get('description', ''),
safe_float(row.get('min_price', 0), 0),
safe_int(row.get('quantity_in_stock', 0), 0),
prod_days,
10
))
loaded_products += 1
print(f" ✅ Загружено {loaded_products} товаров")
# 6. Загрузка заказов
print("6. 📝 Загрузка заказов...")
orders_df = pd.read_excel(orders_file)
loaded_orders = 0
for _, row in orders_df.iterrows():
# Преобразование дат
date_val = row.get('order_date')
if pd.isna(date_val):
order_date = datetime.now().strftime('%Y-%m-%d')
else:
if hasattr(date_val, 'strftime'):
order_date = date_val.strftime('%Y-%m-%d')
else:
order_date = str(date_val)
prod_start = row.get('production_start_date')
if pd.isna(prod_start):
prod_start = None
else:
if hasattr(prod_start, 'strftime'):
prod_start = prod_start.strftime('%Y-%m-%d')
else:
prod_start = str(prod_start)
prod_end = row.get('production_end_date')
if pd.isna(prod_end):
prod_end = None
else:
if hasattr(prod_end, 'strftime'):
prod_end = prod_end.strftime('%Y-%m-%d')
else:
prod_end = str(prod_end)
cursor.execute("""
INSERT INTO Orders
(id, client_id, order_date, total_amount, status,
production_days, production_start_date, production_end_date, production_status,
assembly_percent, delivery_percent, assembly_cost, delivery_cost, base_amount)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
safe_int(row.get('id')),
safe_int(row.get('client_id')),
order_date,
safe_float(row.get('total_amount'), 0),
row.get('status', 'Новый'),
safe_int(row.get('production_days', 1)),
prod_start,
prod_end,
row.get('production_status', 'Ожидает'),
safe_int(row.get('assembly_percent', 15)),
safe_int(row.get('delivery_percent', 10)),
safe_float(row.get('assembly_cost', 0), 0),
safe_float(row.get('delivery_cost', 0), 0),
safe_float(row.get('base_amount', 0), 0)
))
loaded_orders += 1
print(f" ✅ Загружено {loaded_orders} заказов")
# 7. Загрузка состава заказов (OrderItems)
print("7. 📦 Загрузка состава заказов...")
orderitems_df = pd.read_excel(orderitems_file)
loaded_items = 0
for _, row in orderitems_df.iterrows():
cursor.execute("""
INSERT INTO OrderItems (order_id, product_id, quantity, price_at_moment)
VALUES (?, ?, ?, ?)
""", (
safe_int(row.get('order_id')),
safe_int(row.get('product_id')),
safe_int(row.get('quantity'), 1),
safe_float(row.get('price_at_moment'), 0)
))
loaded_items += 1
print(f" ✅ Загружено {loaded_items} позиций")
conn.commit()
print(f"✅ Данные успешно загружены!")
except Exception as e:
print(f"❌ Ошибка при загрузке данных: {e}")
import traceback
traceback.print_exc()
conn.rollback()
finally:
conn.close()
def display_menu():
"""Главное меню программы"""
menu_options = [
'КАРТОЧКИ КЛИЕНТОВ (сортировка по рейтингу)',
'КАРТОЧКИ КЛИЕНТОВ (сортировка по скидкам)',
'ИСТОРИЯ ЗАКАЗОВ',
'ЗАПОЛНЕНИЕ БД из файлов',
'ВЫХОД'
]
while True:
choice = simple_choice(menu_options)
if choice == 'КАРТОЧКИ КЛИЕНТОВ (сортировка по рейтингу)':
print("\n🎯 КАРТОЧКИ КЛИЕНТОВ (сортировка по рейтингу)")
print("═" * 80)
clients = get_clients(sort_by='rating', ascending=False)
print_client_cards(clients)
elif choice == 'КАРТОЧКИ КЛИЕНТОВ (сортировка по скидкам)':
print("\n📊 КАРТОЧКИ КЛИЕНТОВ (сортировка по скидкам)")
print("═" * 80)
clients = get_clients(sort_by='discount', ascending=False)
print_client_cards(clients)
elif choice == 'ИСТОРИЯ ЗАКАЗОВ':
print("\n📈 ИСТОРИЯ ЗАКАЗОВ")
print("═" * 80)
orders = get_orders_history(limit=50)
print_orders_cards(orders)
elif choice == 'ЗАПОЛНЕНИЕ БД из файлов':
if os.path.exists(DB_PATH):
os.remove(DB_PATH)
print("🗑️ База данных удалена")
else:
print("ℹ️ Создаем новую базу")
conn = sqlite3.connect(DB_PATH)
create_tables(conn)
conn.close()
load_data_from_files()
elif choice == 'ВЫХОД':
print("\n👋 До свидания!")
break
if __name__ == "__main__":
display_menu()

897
main_flask.py Normal file
View file

@ -0,0 +1,897 @@
from flask import Flask, render_template, send_from_directory, request, redirect, url_for, flash, session
import sqlite3
import os
from datetime import datetime, timedelta
app = Flask(__name__)
app.secret_key = 'my_secret_key_12345'
# ========== ФИЛЬТР ДЛЯ ФОРМАТИРОВАНИЯ ДАТ ==========
@app.template_filter('date_format')
def date_format(value):
"""Преобразует дату из формата YYYY-MM-DD в ДД.ММ.ГГГГ"""
if not value:
return '-'
try:
# Если value уже строка, пробуем распарсить
if isinstance(value, str) and len(value) >= 10:
# Проверяем, что это похоже на дату в формате YYYY-MM-DD
if value[4] == '-' and value[7] == '-':
year, month, day = value[:10].split('-')
return f"{day}.{month}.{year}"
return value
except:
return value
def get_db_connection():
conn = sqlite3.connect('partners.db')
conn.row_factory = sqlite3.Row
return conn
def dict_from_row(row):
"""Преобразует sqlite3.Row в словарь"""
if row is None:
return None
return {key: row[key] for key in row.keys()}
def list_from_rows(rows):
"""Преобразует список sqlite3.Row в список словарей"""
return [dict_from_row(row) for row in rows]
def calculate_discount(total_amount):
"""Рассчитывает скидку на основе общей суммы заказов в рублях"""
return 0, '0%'
def calculate_order_discount(order_amount):
"""Рассчитывает скидку на текущий заказ в зависимости от его суммы"""
return 0
# ========== ГЛАВНАЯ СТРАНИЦА ==========
@app.route('/')
def index():
# Получаем параметры фильтрации из URL
search_query = request.args.get('search', '')
client_type = request.args.get('type', 'all')
min_rating = request.args.get('rating', '0')
sort_by = request.args.get('sort', 'name_asc')
conn = get_db_connection()
try:
# Получаем общее количество всех клиентов (без фильтров)
total_count = conn.execute('SELECT COUNT(*) as count FROM Clients').fetchone()['count']
# Базовый SQL запрос для клиентов
sql = '''
SELECT c.*, ct.type_name as contractor_type_name,
COALESCE(SUM(o.total_amount), 0) as total_amount,
COALESCE(SUM(oi.quantity), 0) as total_quantity,
COUNT(DISTINCT o.id) as orders_count
FROM Clients c
LEFT JOIN ContractorType ct ON c.contractor_type_id = ct.id
LEFT JOIN Orders o ON c.id = o.client_id
LEFT JOIN OrderItems oi ON o.id = oi.order_id
WHERE 1=1
'''
params = []
# Поиск по названию или ИНН
if search_query:
sql += " AND (c.name LIKE ? OR c.inn LIKE ?)"
params.extend([f'%{search_query}%', f'%{search_query}%'])
# Фильтр по типу клиента
if client_type != 'all':
sql += " AND ct.type_name = ?"
params.append(client_type)
# Фильтр по минимальному рейтингу
if min_rating != '0':
sql += " AND c.rating >= ?"
params.append(float(min_rating))
# Группировка (обязательно после WHERE)
sql += " GROUP BY c.id"
# Сортировка
if sort_by == 'name_asc':
sql += " ORDER BY c.name ASC"
elif sort_by == 'name_desc':
sql += " ORDER BY c.name DESC"
elif sort_by == 'rating_desc':
sql += " ORDER BY c.rating DESC"
elif sort_by == 'rating_asc':
sql += " ORDER BY c.rating ASC"
elif sort_by == 'discount_desc':
sql += " ORDER BY total_amount DESC"
elif sort_by == 'discount_asc':
sql += " ORDER BY total_amount ASC"
else:
sql += " ORDER BY c.name ASC"
# Выполняем запрос и преобразуем в словари
rows = conn.execute(sql, params).fetchall()
clients = list_from_rows(rows)
# Добавляем информацию о скидке на основе суммы
clients_with_discount = []
for client in clients:
total_amount = client['total_amount'] or 0
discount_percent, discount_text = calculate_discount(total_amount)
client['total_sales'] = total_amount
client['discount_percent'] = discount_percent
client['discount'] = discount_percent
client['discount_text'] = discount_text
clients_with_discount.append(client)
conn.close()
return render_template('index.html',
partners=clients_with_discount,
total_count=total_count)
except Exception as e:
conn.close()
print(f"❌ ОШИБКА: {e}")
return render_template('index.html',
partners=[],
total_count=0,
error_message=f"Ошибка базы данных: {str(e)}")
# ========== МАРШРУТЫ ДЛЯ КЛИЕНТОВ ==========
@app.route('/add_client', methods=['GET', 'POST'])
def add_client():
if request.method == 'POST':
try:
name = request.form['name']
director = request.form.get('director', '')
email = request.form['email']
phone = request.form.get('phone', '')
address = request.form.get('address', '')
inn = request.form.get('inn', '')
contractor_type_id = request.form['contractor_type_id']
rating = request.form.get('rating', 0.0)
conn = get_db_connection()
# Проверяем уникальность ИНН
if inn:
existing = conn.execute('SELECT id FROM Clients WHERE inn = ?', (inn,)).fetchone()
if existing:
flash('Клиент с таким ИНН уже существует', 'error')
conn.close()
return redirect(url_for('add_client'))
conn.execute('''
INSERT INTO Clients (name, director, email, phone, address, inn, contractor_type_id, rating)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (name, director, email, phone, address, inn, contractor_type_id, rating))
conn.commit()
conn.close()
flash('Клиент успешно добавлен!', 'success')
return redirect(url_for('index'))
except Exception as e:
flash(f'Ошибка: {str(e)}', 'error')
return redirect(url_for('add_client'))
# GET запрос
conn = get_db_connection()
contractor_types = conn.execute('SELECT * FROM ContractorType ORDER BY type_name').fetchall()
conn.close()
return render_template('add_client.html', contractor_types=contractor_types)
@app.route('/edit_client/<int:client_id>', methods=['GET', 'POST'])
def edit_client(client_id):
conn = get_db_connection()
if request.method == 'POST':
try:
name = request.form['name']
director = request.form.get('director', '')
email = request.form['email']
phone = request.form.get('phone', '')
address = request.form.get('address', '')
inn = request.form.get('inn', '')
contractor_type_id = request.form['contractor_type_id']
rating = request.form.get('rating', 0.0)
# Проверяем уникальность ИНН
if inn:
existing = conn.execute(
'SELECT id FROM Clients WHERE inn = ? AND id != ?',
(inn, client_id)
).fetchone()
if existing:
flash('Клиент с таким ИНН уже существует', 'error')
conn.close()
return redirect(url_for('edit_client', client_id=client_id))
conn.execute('''
UPDATE Clients
SET name = ?, director = ?, email = ?, phone = ?,
address = ?, inn = ?, contractor_type_id = ?, rating = ?
WHERE id = ?
''', (name, director, email, phone, address, inn, contractor_type_id, rating, client_id))
conn.commit()
conn.close()
flash('Данные обновлены!', 'success')
return redirect(url_for('index'))
except Exception as e:
flash(f'Ошибка: {str(e)}', 'error')
conn.close()
return redirect(url_for('edit_client', client_id=client_id))
# GET запрос
client_row = conn.execute('''
SELECT c.*, ct.type_name as contractor_type_name
FROM Clients c
LEFT JOIN ContractorType ct ON c.contractor_type_id = ct.id
WHERE c.id = ?
''', (client_id,)).fetchone()
client_dict = dict_from_row(client_row)
contractor_types = conn.execute('SELECT * FROM ContractorType ORDER BY type_name').fetchall()
conn.close()
if not client_dict:
flash('Клиент не найден', 'error')
return redirect(url_for('index'))
return render_template('edit_client.html', client=client_dict, contractor_types=contractor_types)
# ========== МАРШРУТЫ ДЛЯ ЗАКАЗОВ ==========
@app.route('/order_history/<int:client_id>')
def order_history(client_id):
conn = get_db_connection()
client_row = conn.execute('''
SELECT c.*, ct.type_name as contractor_type_name
FROM Clients c
LEFT JOIN ContractorType ct ON c.contractor_type_id = ct.id
WHERE c.id = ?
''', (client_id,)).fetchone()
client_dict = dict_from_row(client_row)
# Получаем заказы клиента
orders_rows = conn.execute('''
SELECT
o.*
FROM Orders o
WHERE o.client_id = ?
ORDER BY o.order_date DESC, o.id DESC
''', (client_id,)).fetchall()
# Получаем детальную информацию о товарах в каждом заказе
orders_with_items = []
total_quantity_all = 0
total_amount_all = 0
completed_count = 0
for order_row in orders_rows:
order_dict = dict_from_row(order_row)
# Получаем товары для этого заказа
items_rows = conn.execute('''
SELECT oi.*, cat.product_name, cat.article, cat.min_price, cat2.category_name
FROM OrderItems oi
JOIN Catalog cat ON oi.product_id = cat.id
LEFT JOIN Categories cat2 ON cat.category_id = cat2.id
WHERE oi.order_id = ?
''', (order_dict['id'],)).fetchall()
items_list = list_from_rows(items_rows)
order_dict['items'] = items_list
# Считаем общее количество для этого заказа
order_total_qty = sum(item['quantity'] for item in items_list) if items_list else 0
order_dict['total_items'] = order_total_qty
total_quantity_all += order_total_qty
total_amount_all += order_dict['total_amount']
if order_dict['status'] == 'Выполнен':
completed_count += 1
orders_with_items.append(order_dict)
# Статистика
total_stats = {
'total_orders': len(orders_with_items),
'total_quantity': total_quantity_all,
'total_amount': total_amount_all,
'completed_orders': completed_count
}
conn.close()
if not client_dict:
flash('Клиент не найден', 'error')
return redirect(url_for('index'))
return render_template('order_history.html',
client=client_dict,
orders=orders_with_items,
total_stats=total_stats)
# ========== МАРШРУТ ДЛЯ ОБНОВЛЕНИЯ СТАТУСА ЗАКАЗА ==========
@app.route('/update_order_status/<int:order_id>', methods=['POST'])
def update_order_status(order_id):
"""Обновление статуса заказа"""
try:
new_status = request.form['status']
conn = get_db_connection()
conn.execute('UPDATE Orders SET status = ? WHERE id = ?', (new_status, order_id))
conn.commit()
conn.close()
flash(f'Статус заказа обновлен на "{new_status}"', 'success')
except Exception as e:
flash(f'Ошибка при обновлении статуса: {str(e)}', 'error')
return redirect(request.referrer or url_for('orders_list'))
# ========== МАРШРУТ ДЛЯ ОБНОВЛЕНИЯ СТАТУСА ПРОИЗВОДСТВА ==========
@app.route('/update_production_status/<int:order_id>', methods=['POST'])
def update_production_status(order_id):
"""Обновление статуса производства"""
try:
new_status = request.form['production_status']
conn = get_db_connection()
conn.execute('UPDATE Orders SET production_status = ? WHERE id = ?', (new_status, order_id))
conn.commit()
conn.close()
flash(f'Статус производства обновлен на "{new_status}"', 'success')
except Exception as e:
flash(f'Ошибка при обновлении статуса производства: {str(e)}', 'error')
return redirect(request.referrer)
# ========== МАРШРУТЫ ДЛЯ ТОВАРОВ ==========
@app.route('/add_product', methods=['GET', 'POST'])
def add_product():
if request.method == 'POST':
try:
product_name = request.form['product_name']
article = request.form['article']
category_id = request.form['category_id']
material_id = request.form.get('material_id') or None
description = request.form.get('description', '')
min_price = float(request.form['min_price'])
quantity_in_stock = int(request.form.get('quantity_in_stock', 0))
production_time_days = int(request.form.get('production_time_days', 1))
conn = get_db_connection()
existing = conn.execute('SELECT id FROM Catalog WHERE article = ?', (article,)).fetchone()
if existing:
flash('Товар с таким артикулом уже существует', 'error')
conn.close()
return redirect(url_for('add_product'))
conn.execute('''
INSERT INTO Catalog
(product_name, article, category_id, material_id, description, min_price,
quantity_in_stock, production_time_days)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
''', (product_name, article, category_id, material_id, description, min_price,
quantity_in_stock, production_time_days))
conn.commit()
conn.close()
flash('Товар успешно добавлен!', 'success')
return redirect(url_for('catalog'))
except Exception as e:
flash(f'Ошибка: {str(e)}', 'error')
return redirect(url_for('add_product'))
conn = get_db_connection()
categories = conn.execute('SELECT * FROM Categories ORDER BY category_name').fetchall()
materials = conn.execute('SELECT * FROM Materials ORDER BY material_name').fetchall()
conn.close()
return render_template('add_product.html', categories=categories, materials=materials)
@app.route('/edit_product/<int:product_id>', methods=['GET', 'POST'])
def edit_product(product_id):
conn = get_db_connection()
if request.method == 'POST':
try:
product_name = request.form['product_name']
article = request.form['article']
category_id = request.form['category_id']
material_id = request.form.get('material_id') or None
description = request.form.get('description', '')
min_price = float(request.form['min_price'])
quantity_in_stock = int(request.form.get('quantity_in_stock', 0))
production_time_days = int(request.form.get('production_time_days', 1))
existing = conn.execute(
'SELECT id FROM Catalog WHERE article = ? AND id != ?',
(article, product_id)
).fetchone()
if existing:
flash('Товар с таким артикулом уже существует', 'error')
conn.close()
return redirect(url_for('edit_product', product_id=product_id))
conn.execute('''
UPDATE Catalog
SET product_name = ?, article = ?, category_id = ?, material_id = ?,
description = ?, min_price = ?, quantity_in_stock = ?,
production_time_days = ?
WHERE id = ?
''', (product_name, article, category_id, material_id, description, min_price,
quantity_in_stock, production_time_days, product_id))
conn.commit()
conn.close()
flash('Товар успешно обновлен!', 'success')
return redirect(url_for('catalog'))
except Exception as e:
flash(f'Ошибка: {str(e)}', 'error')
return redirect(url_for('edit_product', product_id=product_id))
product_row = conn.execute('''
SELECT cat.*, c.category_name, m.material_name
FROM Catalog cat
LEFT JOIN Categories c ON cat.category_id = c.id
LEFT JOIN Materials m ON cat.material_id = m.id
WHERE cat.id = ?
''', (product_id,)).fetchone()
product_dict = dict_from_row(product_row)
categories = conn.execute('SELECT * FROM Categories ORDER BY category_name').fetchall()
materials = conn.execute('SELECT * FROM Materials ORDER BY material_name').fetchall()
conn.close()
if not product_dict:
flash('Товар не найден', 'error')
return redirect(url_for('catalog'))
return render_template('edit_product.html',
product=product_dict,
categories=categories,
materials=materials)
@app.route('/delete_product/<int:product_id>', methods=['POST'])
def delete_product(product_id):
try:
conn = get_db_connection()
order_items = conn.execute('SELECT COUNT(*) as count FROM OrderItems WHERE product_id = ?', (product_id,)).fetchone()
if order_items['count'] > 0:
flash('Нельзя удалить товар, по которому есть заказы', 'error')
conn.close()
return redirect(url_for('catalog'))
conn.execute('DELETE FROM Catalog WHERE id = ?', (product_id,))
conn.commit()
conn.close()
flash('Товар успешно удален', 'success')
except Exception as e:
flash(f'Ошибка при удалении: {str(e)}', 'error')
return redirect(url_for('catalog'))
@app.route('/catalog')
def catalog():
conn = get_db_connection()
categories = conn.execute('SELECT * FROM Categories ORDER BY category_name').fetchall()
# Получаем список всех клиентов для выпадающего списка
clients_rows = conn.execute('SELECT id, name FROM Clients ORDER BY name').fetchall()
clients = list_from_rows(clients_rows)
search_query = request.args.get('search', '')
category_id = request.args.get('category', 'all')
sort_by = request.args.get('sort', 'name_asc')
sql = '''
SELECT cat.*, c.category_name, m.material_name, m.color,
m.production_time_days, m.daily_capacity
FROM Catalog cat
LEFT JOIN Categories c ON cat.category_id = c.id
LEFT JOIN Materials m ON cat.material_id = m.id
WHERE 1=1
'''
params = []
if search_query:
sql += " AND (cat.product_name LIKE ? OR cat.article LIKE ?)"
params.extend([f'%{search_query}%', f'%{search_query}%'])
if category_id != 'all':
sql += " AND cat.category_id = ?"
params.append(category_id)
if sort_by == 'name_asc':
sql += " ORDER BY cat.product_name ASC"
elif sort_by == 'name_desc':
sql += " ORDER BY cat.product_name DESC"
elif sort_by == 'price_asc':
sql += " ORDER BY cat.min_price ASC"
elif sort_by == 'price_desc':
sql += " ORDER BY cat.min_price DESC"
elif sort_by == 'stock_desc':
sql += " ORDER BY cat.quantity_in_stock DESC"
else:
sql += " ORDER BY cat.product_name ASC"
products_rows = conn.execute(sql, params).fetchall()
products = list_from_rows(products_rows)
cart = session.get('cart', {})
cart_count = sum(item['quantity'] for item in cart.values()) if cart else 0
conn.close()
return render_template('catalog.html',
products=products,
categories=categories,
clients=clients,
cart_count=cart_count)
@app.route('/product/<int:product_id>')
def product_detail(product_id):
conn = get_db_connection()
product_row = conn.execute('''
SELECT cat.*, c.category_name, m.material_name,
m.price_per_unit, m.unit, m.in_stock as material_stock,
m.daily_capacity as material_capacity,
m.production_time_days as material_production_time,
m.cost_price as material_cost_price
FROM Catalog cat
LEFT JOIN Categories c ON cat.category_id = c.id
LEFT JOIN Materials m ON cat.material_id = m.id
WHERE cat.id = ?
''', (product_id,)).fetchone()
product_dict = dict_from_row(product_row)
material = None
if product_dict and product_dict.get('material_id'):
material_row = conn.execute('SELECT * FROM Materials WHERE id = ?', (product_dict['material_id'],)).fetchone()
material = dict_from_row(material_row)
orders_stats_row = conn.execute('''
SELECT
COUNT(DISTINCT oi.order_id) as total_orders,
SUM(oi.quantity) as total_sold,
AVG(oi.quantity) as avg_order_size
FROM OrderItems oi
WHERE oi.product_id = ?
''', (product_id,)).fetchone()
orders_stats_dict = dict_from_row(orders_stats_row)
conn.close()
if not product_dict:
flash('Товар не найден', 'error')
return redirect(url_for('catalog'))
return render_template('product_detail.html',
product=product_dict,
material=material,
orders_stats=orders_stats_dict)
# ========== МАРШРУТЫ ДЛЯ КОРЗИНЫ ==========
@app.route('/cart')
def view_cart():
cart = session.get('cart', {})
cart_items = []
total_amount = 0
conn = get_db_connection()
for key, item_data in cart.items():
product_id = item_data['product_id']
quantity = item_data['quantity']
client_id = item_data['client_id']
# Получаем информацию о товаре
product_row = conn.execute('''
SELECT cat.*, c.category_name, m.material_name, cat.quantity_in_stock
FROM Catalog cat
LEFT JOIN Categories c ON cat.category_id = c.id
LEFT JOIN Materials m ON cat.material_id = m.id
WHERE cat.id = ?
''', (product_id,)).fetchone()
# Получаем информацию о партнере
client_row = conn.execute('SELECT name FROM Clients WHERE id = ?', (client_id,)).fetchone()
client_name = client_row['name'] if client_row else 'Неизвестный'
if product_row:
product = dict_from_row(product_row)
cart_items.append({
'id': key,
'product_id': product_id,
'product_name': product['product_name'],
'article': product['article'],
'category_name': product['category_name'],
'material_name': product['material_name'],
'client_id': client_id,
'client_name': client_name,
'price': product['min_price'],
'quantity': quantity,
'in_stock': product['quantity_in_stock'] or 0
})
total_amount += product['min_price'] * quantity
conn.close()
return render_template('cart_new.html', cart_items=cart_items, total_amount=total_amount)
@app.route('/add_to_cart/<int:product_id>', methods=['POST'])
def add_to_cart(product_id):
quantity = int(request.form.get('quantity', 1))
client_id = request.form.get('client_id')
if not client_id:
flash('Выберите партнера', 'error')
return redirect(url_for('catalog'))
cart = session.get('cart', {})
# Проверяем, что все товары в корзине от одного партнера
if cart:
existing_client = next(iter(cart.values()))['client_id']
if existing_client != int(client_id):
flash('Нельзя добавлять товары разных партнеров в одну корзину', 'error')
return redirect(url_for('catalog'))
# Создаем уникальный ключ для комбинации товар + партнер
key = f"{product_id}_{client_id}"
if key in cart:
cart[key]['quantity'] += quantity
else:
cart[key] = {
'product_id': product_id,
'client_id': int(client_id),
'quantity': quantity
}
session['cart'] = cart
flash('Товар добавлен в корзину', 'success')
return redirect(url_for('catalog'))
@app.route('/update_cart/<path:item_id>', methods=['POST'])
def update_cart(item_id):
quantity = int(request.form.get('quantity', 1))
cart = session.get('cart', {})
if quantity > 0 and item_id in cart:
cart[item_id]['quantity'] = quantity
elif quantity <= 0 and item_id in cart:
cart.pop(item_id, None)
session['cart'] = cart
return ('', 204)
@app.route('/remove_from_cart/<path:item_id>', methods=['POST'])
def remove_from_cart(item_id):
cart = session.get('cart', {})
cart.pop(item_id, None)
session['cart'] = cart
return redirect(url_for('view_cart'))
# ========== МАРШРУТ ДЛЯ ОФОРМЛЕНИЯ ЗАКАЗА ==========
@app.route('/checkout', methods=['POST'])
def checkout():
cart = session.get('cart', {})
if not cart:
flash('Корзина пуста', 'error')
return redirect(url_for('catalog'))
conn = get_db_connection()
# Проверяем, что все товары от одного партнера
client_ids = set(item_data['client_id'] for item_data in cart.values())
if len(client_ids) > 1:
flash('Ошибка: в корзине товары от разных партнеров', 'error')
conn.close()
return redirect(url_for('view_cart'))
client_id = list(client_ids)[0] if client_ids else None
# Собираем все товары
items = []
subtotal = 0
for key, item_data in cart.items():
product_id = item_data['product_id']
quantity = item_data['quantity']
product_row = conn.execute('SELECT * FROM Catalog WHERE id = ?', (product_id,)).fetchone()
if not product_row:
flash(f'Товар с ID {product_id} не найден', 'error')
conn.close()
return redirect(url_for('view_cart'))
product = dict_from_row(product_row)
if product['quantity_in_stock'] < quantity:
flash(f'Недостаточно товара "{product["product_name"]}" на складе', 'error')
conn.close()
return redirect(url_for('view_cart'))
items.append({
'product': product,
'quantity': quantity
})
subtotal += product['min_price'] * quantity
try:
# Рассчитываем наценку 15% + 10% = 25%
assembly_percent = 15
delivery_percent = 10
assembly_cost = subtotal * assembly_percent / 100
delivery_cost = subtotal * delivery_percent / 100
total_amount = subtotal + assembly_cost + delivery_cost
# Срок изготовления (максимальный из всех товаров)
production_days = max(item['product']['production_time_days'] or 1 for item in items)
today = datetime.now().strftime('%Y-%m-%d')
end_date = (datetime.now() + timedelta(days=production_days)).strftime('%Y-%m-%d')
# Создаем ОДИН заказ
cursor = conn.execute('''
INSERT INTO Orders (
client_id, order_date, total_amount, status,
production_days, production_start_date, production_end_date, production_status,
assembly_percent, delivery_percent, assembly_cost, delivery_cost, base_amount
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
client_id, today, total_amount, 'Новый',
production_days, today, end_date, 'Ожидает',
assembly_percent, delivery_percent, assembly_cost, delivery_cost, subtotal
))
order_id = cursor.lastrowid
# Добавляем все товары в OrderItems
for item in items:
product = item['product']
quantity = item['quantity']
conn.execute('''
INSERT INTO OrderItems (order_id, product_id, quantity, price_at_moment)
VALUES (?, ?, ?, ?)
''', (order_id, product['id'], quantity, product['min_price']))
conn.execute('''
UPDATE Catalog
SET quantity_in_stock = quantity_in_stock - ?
WHERE id = ?
''', (quantity, product['id']))
conn.commit()
session.pop('cart', None)
flash(f'✅ Заказ успешно оформлен! Сумма: {total_amount:.2f} руб.', 'success')
except Exception as e:
conn.rollback()
flash(f'Ошибка при создании заказа: {str(e)}', 'error')
conn.close()
return redirect(url_for('orders_list'))
# ========== МАРШРУТ ДЛЯ СПИСКА ВСЕХ ЗАКАЗОВ ==========
@app.route('/orders_list')
def orders_list():
"""Список всех заказов"""
conn = get_db_connection()
clients_rows = conn.execute('SELECT id, name FROM Clients ORDER BY name').fetchall()
clients_list = list_from_rows(clients_rows)
# Получаем заказы с основной информацией
orders_rows = conn.execute('''
SELECT
o.*,
c.name as client_name,
c.inn as client_inn,
COUNT(oi.id) as items_count,
COALESCE(SUM(oi.quantity), 0) as total_items
FROM Orders o
LEFT JOIN Clients c ON o.client_id = c.id
LEFT JOIN OrderItems oi ON o.id = oi.order_id
GROUP BY o.id
ORDER BY o.order_date DESC, o.id DESC
LIMIT 100
''').fetchall()
# Для каждого заказа получаем список товаров
orders_with_items = []
for order_row in orders_rows:
order_dict = dict_from_row(order_row)
# Получаем товары для этого заказа
items_rows = conn.execute('''
SELECT oi.*, cat.product_name, cat.article, cat.min_price, cat.category_id,
cat2.category_name
FROM OrderItems oi
JOIN Catalog cat ON oi.product_id = cat.id
LEFT JOIN Categories cat2 ON cat.category_id = cat2.id
WHERE oi.order_id = ?
''', (order_dict['id'],)).fetchall()
order_dict['items'] = list_from_rows(items_rows)
orders_with_items.append(order_dict)
# Считаем общую статистику
total_stats_row = conn.execute('''
SELECT
COUNT(DISTINCT o.id) as total_orders,
COALESCE(SUM(oi.quantity), 0) as total_quantity,
COALESCE(SUM(o.total_amount), 0) as total_amount
FROM Orders o
LEFT JOIN OrderItems oi ON o.id = oi.order_id
''').fetchone()
total_stats = dict_from_row(total_stats_row)
conn.close()
return render_template('orders_list.html',
orders=orders_with_items,
total_stats=total_stats,
clients=clients_list)
# ========== МАРШРУТ ДЛЯ МАТЕРИАЛОВ ==========
@app.route('/materials')
def materials():
conn = get_db_connection()
materials_rows = conn.execute('''
SELECT * FROM Materials
ORDER BY material_name
''').fetchall()
materials_list = list_from_rows(materials_rows)
conn.close()
return render_template('materials.html', materials=materials_list)
# ========== СЛУЖЕБНЫЕ МАРШРУТЫ ==========
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/static/<path:filename>')
def static_files(filename):
return send_from_directory(app.root_path + '/static', filename)
if __name__ == '__main__':
app.run(debug=True)

BIN
partners.db Normal file

Binary file not shown.

BIN
static/OfficeStyle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

419
static/css/style.css Normal file
View file

@ -0,0 +1,419 @@
/* Подключаем шрифт Franklin Gothic */
@import url('https://fonts.googleapis.com/css2?family=Franklin+Gothic:wght@400;500;600;700&display=swap');
body {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
background-color: #F0F8FF; /* Алиса синий */
margin: 0;
padding: 0;
}
.main-container {
background-color: #ffffff;
min-height: 100vh;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #4682B4 0%, #B0C4DE 100%); /* Стальной синий к светло-стальному */
padding: 25px;
border-radius: 12px;
margin-bottom: 35px;
color: white;
box-shadow: 0 6px 20px rgba(70, 130, 180, 0.3);
}
.logo-container {
display: flex;
align-items: center;
gap: 25px;
}
.logo {
height: 70px;
width: auto;
object-fit: contain;
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.2));
}
.header-title {
font-size: 2.5em;
font-weight: 700;
margin: 0;
text-shadow: 2px 2px 6px rgba(0,0,0,0.3);
letter-spacing: 0.5px;
}
.page-title {
color: #2c3e50;
font-weight: 700;
border-bottom: 3px solid #4682B4;
padding-bottom: 10px;
margin-bottom: 25px;
}
.partner-card {
border: 3px solid #B0C4DE; /* Светло-стальной */
border-radius: 15px;
padding: 25px;
margin-bottom: 25px;
background: #ffffff;
box-shadow: 0 6px 20px rgba(176, 196, 222, 0.25);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.partner-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 25px rgba(176, 196, 222, 0.35);
}
.partner-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 3px solid #e9ecef;
padding-bottom: 18px;
margin-bottom: 20px;
}
.partner-name-line {
display: flex;
align-items: center;
gap: 15px;
flex-wrap: wrap;
}
.partner-type {
font-size: 1.2em;
font-weight: 600;
color: #4682B4; /* Стальной синий */
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%); /* Градиент алиса синий */
padding: 8px 18px;
border-radius: 25px;
border: 2px solid #B0C4DE;
}
.partner-name {
font-size: 1.6em;
font-weight: 700;
color: #2c3e50;
margin: 0;
}
.divider {
color: #B0C4DE; /* Светло-стальной */
font-weight: bold;
font-size: 1.5em;
}
.discount-info {
display: flex;
align-items: center;
gap: 20px;
}
.discount-badge {
font-size: 1.2em;
padding: 10px 20px;
border-radius: 30px;
background: linear-gradient(135deg, #4682B4 0%, #5A8EC6 100%);
color: white;
font-weight: 600;
box-shadow: 0 3px 10px rgba(70, 130, 180, 0.3);
}
.sales-amount {
font-size: 1.1em;
color: #4682B4; /* Стальной синий */
font-weight: 600;
background-color: #F0F8FF; /* Алиса синий */
padding: 8px 15px;
border-radius: 8px;
}
.partner-info-row {
margin-bottom: 0;
padding: 8px 0;
line-height: 1.5;
border-bottom: 1px dashed #E6F2FF;
}
.partner-info-row:last-child {
border-bottom: none;
}
.partner-label {
font-weight: 700;
color: #4682B4; /* Стальной синий */
min-width: 140px;
display: inline-block;
font-size: 1.05em;
}
.partner-value {
font-size: 1.05em;
color: #2c3e50;
font-weight: 500;
}
.partner-buttons-container {
display: flex;
gap: 15px;
justify-content: flex-start;
margin-top: 25px;
padding-top: 20px;
border-top: 3px solid #E6F2FF;
}
.btn-action {
padding: 12px 25px;
font-size: 1em;
display: flex;
align-items: center;
gap: 10px;
min-width: 200px;
justify-content: center;
border: none;
border-radius: 10px;
font-weight: 700;
transition: all 0.3s ease;
cursor: pointer;
letter-spacing: 0.5px;
}
.btn-edit {
background: linear-gradient(135deg, #4682B4 0%, #5A8EC6 100%);
color: white;
}
.btn-add {
background: linear-gradient(135deg, #6c757d 0%, #868e96 100%);
color: white;
}
.btn-history {
background: linear-gradient(135deg, #495057 0%, #6c757d 100%);
color: white;
}
.btn-action:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(0,0,0,0.25);
color: white;
}
.btn-new-partner {
background: linear-gradient(135deg, #4682B4 0%, #5A8EC6 100%);
color: white;
border: none;
padding: 14px 28px;
font-weight: 700;
border-radius: 10px;
font-size: 1.1em;
letter-spacing: 0.5px;
}
.btn-new-partner:hover {
background: linear-gradient(135deg, #5A8EC6 0%, #6B9BD4 100%);
color: white;
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(70, 130, 180, 0.3);
}
.rating-badge {
font-size: 1em;
padding: 8px 16px;
border-radius: 20px;
background: linear-gradient(135deg, #FFD700 0%, #FFC400 100%);
color: #2c3e50;
font-weight: 700;
}
.alert {
border-radius: 12px;
border: none;
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%);
border-left: 6px solid #4682B4;
}
.volume-info {
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%);
padding: 12px 20px;
border-radius: 10px;
margin-top: 15px;
border: 2px solid #B0C4DE;
}
.volume-label {
font-weight: 700;
color: #4682B4; /* Стальной синий */
font-size: 1.1em;
}
.volume-value {
color: #2c3e50;
font-weight: 700;
font-size: 1.2em;
}
/* Стили для страницы истории продаж мебели */
.table-responsive {
border-radius: 12px;
overflow: hidden;
border: 3px solid #B0C4DE;
box-shadow: 0 6px 20px rgba(176, 196, 222, 0.3);
}
.table {
margin-bottom: 0;
border-collapse: separate;
border-spacing: 0;
}
.table th {
background: linear-gradient(135deg, #4682B4 0%, #5A8EC6 100%);
color: white;
font-weight: 700;
padding: 16px;
text-align: center;
font-size: 1.1em;
border: none;
}
.table td {
padding: 14px;
text-align: center;
vertical-align: middle;
border-bottom: 2px solid #E6F2FF;
font-size: 1.05em;
}
.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(176, 196, 222, 0.08);
}
.table-striped tbody tr:hover {
background-color: rgba(70, 130, 180, 0.15);
transition: background-color 0.3s ease;
}
.table tfoot {
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%);
font-weight: 700;
font-size: 1.1em;
}
.table tfoot td {
border-top: 3px solid #4682B4;
padding: 18px;
}
.text-end {
text-align: right !important;
}
/* Стили для иконок в кнопках */
.btn-action i {
font-size: 1.2em;
}
/* Стили для блока с расчетом стоимости */
.calculation-block {
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%);
border: 3px solid #B0C4DE;
border-radius: 15px;
padding: 25px;
margin-bottom: 30px;
box-shadow: 0 6px 20px rgba(176, 196, 222, 0.25);
}
.calculation-title {
color: #4682B4;
font-weight: 700;
border-bottom: 3px solid #4682B4;
padding-bottom: 15px;
margin-bottom: 25px;
}
.calculation-result {
background-color: white;
border: 2px solid #B0C4DE;
border-radius: 10px;
padding: 20px;
margin-top: 20px;
}
.result-item {
padding: 10px 0;
border-bottom: 1px dashed #E6F2FF;
}
.result-item:last-child {
border-bottom: none;
font-weight: 700;
font-size: 1.2em;
color: #4682B4;
}
/* Адаптивность для таблицы */
@media (max-width: 768px) {
.table-responsive {
font-size: 0.9em;
}
.table th, .table td {
padding: 10px 6px;
}
.btn-action {
min-width: 150px;
padding: 10px 15px;
font-size: 0.9em;
}
.partner-card {
padding: 20px;
}
.header-title {
font-size: 1.8em;
}
}
/* Стили для фильтров и сортировки */
.filter-section {
background: linear-gradient(135deg, #F0F8FF 0%, #E6F2FF 100%);
border: 2px solid #B0C4DE;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
}
.filter-title {
color: #4682B4;
font-weight: 700;
margin-bottom: 15px;
}
.form-control, .form-select {
border: 2px solid #B0C4DE;
border-radius: 8px;
padding: 10px 15px;
font-size: 1em;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: #4682B4;
box-shadow: 0 0 0 0.25rem rgba(70, 130, 180, 0.25);
}
/* Анимации */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.partner-card, .table-responsive, .calculation-block {
animation: fadeIn 0.5s ease-out;
}

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

37
static/js/script.js Normal file
View file

@ -0,0 +1,37 @@
document.addEventListener('DOMContentLoaded', function() {
// Кнопка нового партнера
const newPartnerBtn = document.querySelector('.btn-new-partner');
if (newPartnerBtn) {
newPartnerBtn.addEventListener('click', function() {
// Переход на страницу добавления партнера
window.location.href = '/add_partner';
});
}
// Кнопка "Редактирование"
document.querySelectorAll('.btn-edit').forEach(btn => {
btn.addEventListener('click', function() {
const partnerId = this.getAttribute('data-partner-id');
// Переход на страницу редактирования
window.location.href = `/edit_partner/${partnerId}`;
});
});
// Кнопка "Добавление продажи"
document.querySelectorAll('.btn-add').forEach(btn => {
btn.addEventListener('click', function() {
const partnerId = this.getAttribute('data-partner-id');
// Переход на страницу добавления продажи
window.location.href = `/add_sale/${partnerId}`;
});
});
// Кнопка "История реализации"
document.querySelectorAll('.btn-history').forEach(btn => {
btn.addEventListener('click', function() {
const partnerId = this.getAttribute('data-partner-id');
// Переход на страницу истории реализации
window.location.href = `/sales_history/${partnerId}`;
});
});
});

18
templates/about.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>About Flask</title>
</head>
<body>
{% extends "template.html" %}
{% block content %}
<h1> About Flask </h1>
<p> Flask is a micro web framework written in Python.</p>
<p> Applications that use the Flask framework include Pinterest,
LinkedIn, and the community web page for Flask itself.</p>
{% endblock %}
</body>
</html>

378
templates/add_client.html Normal file
View file

@ -0,0 +1,378 @@
{% extends "base.html" %}
{% block title %}Добавление клиента{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h2 class="card-title mb-0">
<i class="fas fa-user-plus"></i> Добавление нового клиента
</h2>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" id="clientForm">
<div class="row">
<!-- Основная информация -->
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3" style="color: var(--steel-blue);">Основная информация</h5>
<div class="mb-3">
<label for="name" class="form-label">Название компании (ООО "Ромашка")</label>
<input type="text" class="form-control" id="name" name="name" required maxlength="255"
onkeypress="return blockNumbers(event)" oninput="this.value = this.value.replace(/[0-9]/g, '')"
placeholder="Например: ООО МебельСтиль">
</div>
<div class="mb-3">
<label for="director" class="form-label">Директор (ФИО)</label>
<input type="text" class="form-control" id="director" name="director" maxlength="255"
onkeypress="return blockNumbers(event)" oninput="this.value = this.value.replace(/[0-9]/g, '')"
placeholder="Фамилия Имя Отчество">
</div>
<div class="mb-3">
<label for="contractor_type_id" class="form-label">Тип клиента</label>
<select class="form-select" id="contractor_type_id" name="contractor_type_id" required>
<option value="">Выберите тип клиента</option>
{% for type in contractor_types %}
<option value="{{ type.id }}">{{ type.type_name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="rating" class="form-label">Рейтинг (от 0.0 до 5.0)</label>
<input type="number" class="form-control" id="rating" name="rating"
min="0" max="5" step="0.1" value="5.0"
placeholder="Например: 4.5">
<div class="form-text">От 0.0 до 5.0</div>
</div>
</div>
<!-- Контактная информация -->
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3" style="color: var(--steel-blue);">Контактная информация</h5>
<div class="mb-3">
<label for="email" class="form-label">Email (company@gmail.com)</label>
<input type="email" class="form-control" id="email" name="email" required maxlength="255"
onkeypress="return allowEnglishOnly(event)" oninput="this.value = this.value.replace(/[а-яА-ЯёЁ]/g, '')"
placeholder="Например: info@company.ru">
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон (+7 999 999-99-99)</label>
<input type="tel" class="form-control" id="phone" name="phone" maxlength="50"
onkeypress="return allowPhoneNumbers(event)"
placeholder="+7 (999) 999-99-99">
</div>
<div class="mb-3">
<label for="inn" class="form-label">ИНН (10 или 12 цифр)</label>
<input type="text" class="form-control" id="inn" name="inn" maxlength="12"
onkeypress="return allowOnlyNumbers(event)"
oninput="this.value = this.value.replace(/[^0-9]/g, '').slice(0, 12)"
placeholder="Например: 7701123456">
<div class="form-text">Только цифры, 10 или 12 цифр</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">Юридический адрес (г. Москва, ул. Ленина, д. 1)</label>
<textarea class="form-control" id="address" name="address" rows="3" maxlength="500"
placeholder="Например: 127000, г. Москва, ул. Ленина, д. 5, офис 10"></textarea>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="d-flex justify-content-between">
<a href="{{ url_for('index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Назад к списку
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Добавить клиента
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control, .form-select {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.form-control:hover, .form-select:hover {
border-color: var(--steel-blue);
}
.btn-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.btn-success:hover {
background: linear-gradient(135deg, #218838 0%, #28a745 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(40, 167, 69, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.alert {
border-radius: 10px;
border: 1px solid var(--light-steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-success {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
color: #155724;
border-left: 6px solid #28a745;
}
.alert-danger {
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
color: #721c24;
border-left: 6px solid #dc3545;
}
.border-bottom {
border-color: var(--light-steel-blue) !important;
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.form-text {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #6c757d;
}
input::placeholder, textarea::placeholder {
color: #a0aec0;
font-style: italic;
font-size: 0.9rem;
}
</style>
<script>
// Запрет на ввод цифр в текстовые поля (название компании, ФИО)
function blockNumbers(event) {
const key = event.key;
// Разрешаем: буквы, пробел, backspace, delete, tab, enter, стрелки
if (key === ' ' || key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
// Запрещаем цифры
if (key >= '0' && key <= '9') {
event.preventDefault();
return false;
}
return true;
}
// Только латинские буквы для email
function allowEnglishOnly(event) {
const key = event.key;
// Разрешаем: латинские буквы, цифры, специальные символы для email
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End' ||
key === '@' || key === '.' || key === '_' || key === '-') {
return true;
}
// Проверяем, является ли символ латинской буквой или цифрой
const charCode = event.keyCode || event.which;
const isLatin = (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122);
const isNumber = (charCode >= 48 && charCode <= 57);
if (isLatin || isNumber) {
return true;
}
event.preventDefault();
return false;
}
// Разрешаем только цифры (для ИНН)
function allowOnlyNumbers(event) {
const key = event.key;
// Разрешаем: цифры, backspace, delete, tab, enter, стрелки
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
// Разрешаем только цифры
if (key >= '0' && key <= '9') {
return true;
}
event.preventDefault();
return false;
}
// Для поля телефона разрешаем цифры и некоторые символы
function allowPhoneNumbers(event) {
const key = event.key;
// Разрешаем: цифры, +, -, (, ), пробел, backspace и т.д.
const allowedKeys = ['+', '-', '(', ')', ' '];
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
if (allowedKeys.includes(key)) {
return true;
}
if (key >= '0' && key <= '9') {
return true;
}
event.preventDefault();
return false;
}
// Валидация формы
document.getElementById('clientForm').addEventListener('submit', function(e) {
const inn = document.getElementById('inn').value;
const name = document.getElementById('name').value;
const director = document.getElementById('director').value;
const email = document.getElementById('email').value;
// Проверка на наличие цифр в названии компании
if (name.match(/[0-9]/)) {
e.preventDefault();
alert('Название компании не должно содержать цифры');
return false;
}
// Проверка на наличие цифр в ФИО директора
if (director && director.match(/[0-9]/)) {
e.preventDefault();
alert('ФИО директора не должно содержать цифры');
return false;
}
// Проверка ИНН
if (inn && !/^\d+$/.test(inn)) {
e.preventDefault();
alert('ИНН должен содержать только цифры');
return false;
}
if (inn && (inn.length !== 10 && inn.length !== 12)) {
e.preventDefault();
alert('ИНН должен содержать 10 или 12 цифр');
return false;
}
// Проверка email на наличие русских букв
if (email.match(/[а-яА-ЯёЁ]/)) {
e.preventDefault();
alert('Email не должен содержать русские буквы');
return false;
}
// Проверка домена email
const allowedDomains = ['gmail.com', 'yandex.ru', 'mail.ru', 'bk.ru', 'list.ru', 'inbox.ru', 'rambler.ru', 'ya.ru'];
const domain = email.split('@')[1];
if (domain) {
const isDomainAllowed = allowedDomains.some(allowed => domain === allowed || domain.endsWith('.' + allowed));
if (!isDomainAllowed && !domain.endsWith('.ru') && !domain.endsWith('.com')) {
if (!confirm('Домен ' + domain + ' не входит в список популярных. Продолжить?')) {
e.preventDefault();
return false;
}
}
}
const rating = parseFloat(document.getElementById('rating').value);
if (rating < 0 || rating > 5) {
e.preventDefault();
alert('Рейтинг должен быть от 0.0 до 5.0');
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
e.preventDefault();
alert('Введите корректный email адрес');
return false;
}
});
</script>
{% endblock %}

223
templates/add_product.html Normal file
View file

@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}Добавление товара{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h2 class="card-title mb-0">
<i class="fas fa-box"></i> Добавление товара
</h2>
</div>
<div class="card-body">
<form method="POST" id="productForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="product_name" class="form-label">Наименование</label>
<input type="text" class="form-control" id="product_name" name="product_name" required maxlength="255"
onkeypress="return isLetterKey(event)"
oninput="this.value = this.value.replace(/[^а-яА-ЯёЁa-zA-Z\s]/g, '')">
<small class="form-text text-muted">Только буквы</small>
</div>
<div class="col-md-6 mb-3">
<label for="article" class="form-label">Артикул</label>
<input type="text" class="form-control" id="article" name="article" readonly
value="{{ '%07d'|format(range(1, 10000000)|random) }}">
<small class="form-text text-muted">Генерируется автоматически</small>
</div>
<div class="col-md-6 mb-3">
<label for="category_id" class="form-label">Категория</label>
<select class="form-select" id="category_id" name="category_id" required>
<option value="">Выберите категорию</option>
{% for category in categories %}
<option value="{{ category.id }}">{{ category.category_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="material_id" class="form-label">Материал</label>
<select class="form-select" id="material_id" name="material_id">
<option value="">Не выбран</option>
{% for material in materials %}
<option value="{{ material.id }}">{{ material.material_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3" maxlength="500"></textarea>
</div>
<div class="col-md-6 mb-3">
<label for="production_time_days" class="form-label">Время изготовления (дней)</label>
<input type="number" class="form-control" id="production_time_days" name="production_time_days"
min="1" value="1" required onkeypress="return isNumberKey(event)">
<small class="form-text text-muted">Сколько дней нужно на производство</small>
</div>
<div class="col-md-6 mb-3">
<label for="min_price" class="form-label">Цена</label>
<input type="number" class="form-control" id="min_price" name="min_price"
min="0" step="0.01" required value="0.00" onkeypress="return isNumberKey(event)">
</div>
<div class="col-md-6 mb-3">
<label for="quantity_in_stock" class="form-label">Количество на складе</label>
<input type="number" class="form-control" id="quantity_in_stock" name="quantity_in_stock"
min="0" value="0" onkeypress="return isNumberKey(event)">
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between">
<a href="{{ url_for('catalog') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Назад в каталог
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// Функция для проверки ввода только цифр
function isNumberKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
return false;
}
return true;
}
// Функция для проверки ввода только букв
function isLetterKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
// Разрешаем буквы, пробел, backspace, delete, tab, enter
if (charCode == 32 || charCode == 8 || charCode == 46 || charCode == 9 || charCode == 13) {
return true;
}
// Русские и английские буквы
if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122) ||
(charCode >= 1040 && charCode <= 1103)) {
return true;
}
return false;
}
document.getElementById('productForm').addEventListener('submit', function(e) {
const price = parseFloat(document.getElementById('min_price').value);
if (price <= 0) {
e.preventDefault();
alert('Цена должна быть больше 0');
return false;
}
const prodDays = parseInt(document.getElementById('production_time_days').value);
if (prodDays < 1) {
e.preventDefault();
alert('Время изготовления должно быть не менее 1 дня');
return false;
}
// Проверяем наименование на наличие цифр
const productName = document.getElementById('product_name').value;
if (/\d/.test(productName)) {
e.preventDefault();
alert('Наименование не должно содержать цифры');
return false;
}
});
// Очищаем поле наименования от цифр при вводе
document.getElementById('product_name').addEventListener('input', function() {
this.value = this.value.replace(/[0-9]/g, '');
});
</script>
<style>
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control, .form-select {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.form-text {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #6c757d;
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
/* Стиль для readonly поля */
#article[readonly] {
background-color: #f8f9fa;
cursor: not-allowed;
}
</style>
{% endblock %}

620
templates/base.html Normal file
View file

@ -0,0 +1,620 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}OfficeStyle - Офисная мебель{% endblock %}</title>
<!-- Bootstrap 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome Icons -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<!-- Franklin Gothic Font -->
<link href="https://fonts.googleapis.com/css2?family=Franklin+Gothic:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Favicon -->
<link rel="icon" href="{{ url_for('static', filename='OfficeStyle.png') }}" type="image/png">
<!-- Inline Styles -->
<style>
:root {
--alice-blue: #F0F8FF;
--light-steel-blue: #B0C4DE;
--steel-blue: #4682B4;
}
body {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
background-color: var(--alice-blue);
margin: 0;
padding: 0;
}
.main-container {
background-color: #ffffff;
min-height: 100vh;
padding: 0;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px 30px;
}
/* Навигационные вкладки */
.nav-tabs-section {
border-bottom: 2px solid var(--light-steel-blue);
margin-bottom: 25px;
}
.nav-tabs {
border-bottom: none;
gap: 5px;
}
.nav-tabs .nav-link {
color: #6c757d;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
font-weight: 500;
border: 2px solid transparent;
border-radius: 10px 10px 0 0;
padding: 12px 25px;
transition: all 0.2s ease;
font-size: 1.1rem;
}
.nav-tabs .nav-link i {
margin-right: 8px;
font-size: 1.1rem;
}
.nav-tabs .nav-link:hover {
color: var(--steel-blue);
border-color: var(--light-steel-blue) var(--light-steel-blue) transparent;
background-color: rgba(176, 196, 222, 0.1);
}
.nav-tabs .nav-link.active {
color: var(--steel-blue);
font-weight: 600;
border-color: var(--light-steel-blue) var(--light-steel-blue) transparent;
border-bottom: 2px solid white;
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
}
/* Заголовки страниц */
.page-title {
color: var(--steel-blue);
font-size: 2rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
margin: 0;
}
/* Карточки */
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 8px 20px rgba(70, 130, 180, 0.15);
}
.card-header {
background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%);
color: white;
border-radius: 15px 15px 0 0 !important;
padding: 15px 20px;
}
.card-header h2, .card-header h5 {
margin: 0;
font-weight: 600;
}
/* Кнопки */
.btn {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: none;
border-radius: 8px;
padding: 10px 20px;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%);
color: white;
}
.btn-success:hover {
background: linear-gradient(135deg, #218838 0%, #28a745 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(40, 167, 69, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
color: #2c3e50;
}
.btn-info {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.btn-info:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-back {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
transition: all 0.2s ease;
}
.btn-back:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
/* Формы */
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
margin-bottom: 5px;
}
.form-control, .form-select {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.form-control:hover, .form-select:hover {
border-color: var(--steel-blue);
}
.form-text {
color: #6c757d;
font-size: 0.85rem;
margin-top: 5px;
}
/* Таблицы */
.table {
background-color: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.table thead th {
background-color: var(--steel-blue);
color: white;
font-weight: 600;
border: none;
padding: 12px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.table tbody tr:hover {
background-color: rgba(176, 196, 222, 0.2);
}
.table tbody td {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #2c3e50;
vertical-align: middle;
}
.table a {
color: var(--steel-blue);
text-decoration: none;
font-weight: 500;
}
.table a:hover {
text-decoration: underline;
}
/* Бейджи и статусы */
.badge {
padding: 8px 12px;
font-size: 0.85rem;
border-radius: 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
font-weight: 500;
}
.badge.bg-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%) !important;
color: white !important;
}
.badge.bg-warning {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%) !important;
color: #2c3e50 !important;
}
.badge.bg-danger {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
color: white !important;
}
.badge.bg-info {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%) !important;
color: white !important;
}
.badge.bg-secondary {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%) !important;
color: white !important;
}
/* Статистика */
.stats-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
height: 100%;
}
.stat-item {
text-align: center;
padding: 15px;
background: linear-gradient(135deg, white 0%, #f8f9fa 100%);
border: 2px solid var(--light-steel-blue);
border-radius: 12px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.15);
transition: all 0.2s ease;
height: 100%;
}
.stat-item:hover {
transform: translateY(-3px);
box-shadow: 0 8px 15px rgba(70, 130, 180, 0.25);
border-color: var(--steel-blue);
}
.stat-number {
color: #2c3e50;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 5px;
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Информационные блоки */
.info-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.info-item {
margin-bottom: 10px;
padding: 8px 15px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
}
.info-label {
color: var(--steel-blue);
font-weight: 600;
margin-right: 10px;
}
.info-value {
color: #2c3e50;
}
/* Уведомления */
.flashed-messages {
margin-bottom: 20px;
}
.alert {
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border-left: 6px solid var(--steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #2c3e50;
}
.alert-success {
border-left-color: #28a745;
}
.alert-danger {
border-left-color: #dc3545;
}
.alert-info {
border-left-color: var(--steel-blue);
}
.alert-link {
color: var(--steel-blue);
font-weight: 600;
text-decoration: none;
}
.alert-link:hover {
text-decoration: underline;
}
/* Модальное окно */
.modal-header {
background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%);
color: white;
border-radius: 15px 15px 0 0;
}
.modal-content {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
}
.btn-close-white {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Адаптация для мобильных */
@media (max-width: 768px) {
.container {
padding: 15px;
}
.page-title {
font-size: 1.5rem;
}
.nav-tabs .nav-link {
padding: 8px 15px;
font-size: 0.9rem;
}
.stat-number {
font-size: 1.5rem;
}
.btn {
padding: 8px 15px;
font-size: 0.9rem;
}
}
</style>
</head>
<body>
<div class="main-container">
<div class="container">
{% block header %}
{% include 'components/header.html' %}
{% endblock %}
<!-- Навигационные вкладки - показываем только на основных страницах -->
{% if request.endpoint in ['index', 'orders_list', 'catalog'] %}
<div class="nav-tabs-section mb-4">
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'index' %}active{% endif %}" href="{{ url_for('index') }}">
<i class="fas fa-industry"></i> Партнеры
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'orders_list' %}active{% endif %}" href="{{ url_for('orders_list') }}">
<i class="fas fa-shopping-cart"></i> Заказы
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'catalog' %}active{% endif %}" href="{{ url_for('catalog') }}">
<i class="fas fa-box"></i> Товары
</a>
</li>
</ul>
</div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="flashed-messages">
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<i class="fas fa-info-circle me-2"></i>{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}
{% endblock %}
</div>
</div>
<!-- Project Info Modal -->
<div class="modal fade" id="projectInfoModal" tabindex="-1" aria-labelledby="projectInfoModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="projectInfoModalLabel">
<i class="fas fa-info-circle"></i> О проекте OfficeStyle
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>Система учета офисной мебели</h5>
<p><strong>Цель проекта:</strong> Управление производителями, типами мебели и расчет стоимости с учетом сборки и доставки.</p>
<h6>Основные функции:</h6>
<ul>
<li>Учет производителей мебели</li>
<li>Каталог типов офисной мебели</li>
<li>История заказов</li>
<li>Расчет стоимости с учетом сборки и доставки</li>
<li>Автоматические скидки</li>
</ul>
<div class="color-palette mt-3">
<h6>Цветовая палитра проекта:</h6>
<div class="d-flex gap-2">
<div style="width: 40px; height: 40px; background-color: #F0F8FF; border: 1px solid #ddd;"
data-bs-toggle="tooltip" title="Алиса синий (#F0F8FF)"></div>
<div style="width: 40px; height: 40px; background-color: #B0C4DE; border: 1px solid #ddd;"
data-bs-toggle="tooltip" title="Светло-стальной (#B0C4DE)"></div>
<div style="width: 40px; height: 40px; background-color: #4682B4; border: 1px solid #ddd;"
data-bs-toggle="tooltip" title="Стальной синий (#4682B4)"></div>
</div>
</div>
<div class="mt-3">
<h6>Технологии:</h6>
<span class="badge bg-primary me-1">Flask</span>
<span class="badge bg-success me-1">SQLite</span>
<span class="badge bg-info me-1">Bootstrap 5</span>
<span class="badge bg-warning me-1">Font Awesome</span>
<span class="badge bg-secondary me-1">Franklin Gothic</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Custom JavaScript -->
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
<!-- Inline JavaScript -->
<script>
// Initialize tooltips and popovers
document.addEventListener('DOMContentLoaded', function() {
// Tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Popovers
var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]'));
var popoverList = popoverTriggerList.map(function (popoverTriggerEl) {
return new bootstrap.Popover(popoverTriggerEl);
});
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
var alerts = document.querySelectorAll('.alert:not(.alert-permanent)');
alerts.forEach(function(alert) {
var bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
});
}, 5000);
});
// Function to show project info modal
function showProjectInfo() {
var projectInfoModal = new bootstrap.Modal(document.getElementById('projectInfoModal'));
projectInfoModal.show();
}
// Form validation helper
function validateForm(formId) {
var form = document.getElementById(formId);
if (form.checkValidity() === false) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
return form.checkValidity();
}
// Calculate furniture cost
function calculateFurnitureCost(basePrice, quantity, assemblyCoeff, deliveryCoeff) {
if (!basePrice || !quantity) {
alert('Введите базовую цену и количество');
return null;
}
basePrice = parseFloat(basePrice);
quantity = parseInt(quantity);
assemblyCoeff = parseFloat(assemblyCoeff) || 1.0;
deliveryCoeff = parseFloat(deliveryCoeff) || 1.0;
const baseCost = basePrice * quantity;
const assemblyCost = baseCost * (assemblyCoeff - 1.0);
const deliveryCost = baseCost * (deliveryCoeff - 1.0);
const totalCost = baseCost + assemblyCost + deliveryCost;
return {
baseCost: baseCost,
assemblyCost: assemblyCost,
deliveryCost: deliveryCost,
totalCost: totalCost
};
}
</script>
{% block scripts %}
{% endblock %}
</body>
</html>

434
templates/cart_new.html Normal file
View file

@ -0,0 +1,434 @@
{% extends "base.html" %}
{% block title %}Моя корзина{% endblock %}
{% block content %}
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">Моя корзина</h2>
<a href="{{ url_for('catalog') }}" class="btn btn-primary">
<i class="fas fa-arrow-left"></i> Продолжить покупки
</a>
</div>
{% if cart_items %}
<!-- Таблица товаров в корзине -->
<div class="table-responsive">
<table class="table table-hover" id="cartTable">
<thead>
<tr>
<th>Товар</th>
<th>Артикул</th>
<th>Категория</th>
<th>Материал</th>
<th>Цена</th>
<th style="text-align: center;">Количество</th>
<th>Сумма</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="cartBody">
{% for item in cart_items %}
{% set item_total = item.price * item.quantity %}
<tr id="item-{{ item.id }}" data-price="{{ item.price }}" data-max="{{ item.in_stock }}">
<td>{{ item.product_name }}</td>
<td>{{ item.article }}</td>
<td>{{ item.category_name }}</td>
<td>{{ item.material_name or '-' }}</td>
<td class="item-price">{{ item.price|int }} руб.</td>
<td style="text-align: center;">
<div class="quantity-control">
<button type="button" class="quantity-btn quantity-minus" data-item-id="{{ item.id }}">−</button>
<input type="number" class="quantity-input"
value="{{ item.quantity }}" min="1" max="{{ item.in_stock }}"
data-item-id="{{ item.id }}"
id="quantity-{{ item.id }}"
onkeypress="return isNumberKey(event)">
<button type="button" class="quantity-btn quantity-plus" data-item-id="{{ item.id }}">+</button>
<span class="stock-error" id="error-{{ item.id }}" style="display: none;">
Макс. {{ item.in_stock }}
</span>
</div>
</td>
<td class="item-total fw-bold">{{ item_total|int }} руб.</td>
<td>
<button class="btn btn-sm btn-outline-danger remove-item"
data-item-id="{{ item.id }}"
data-item-name="{{ item.product_name }}">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Расчет стоимости -->
<div class="row mt-4">
<div class="col-md-6 offset-md-6">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h5 class="mb-0"><i class="fas fa-calculator"></i> Итого по заказу</h5>
</div>
<div class="card-body">
{% set ns = namespace(subtotal=0) %}
{% for item in cart_items %}
{% set ns.subtotal = ns.subtotal + (item.price * item.quantity) %}
{% endfor %}
{% set assembly_percent = 15 %}
{% set delivery_percent = 10 %}
{% set assembly_amount = ns.subtotal * assembly_percent // 100 %}
{% set delivery_amount = ns.subtotal * delivery_percent // 100 %}
{% set total = ns.subtotal + assembly_amount + delivery_amount %}
<div class="row mb-2">
<div class="col-6">Сумма без услуг:</div>
<div class="col-6 text-end fw-bold" id="cart-subtotal">{{ ns.subtotal|int }} руб.</div>
</div>
<div class="row mb-2">
<div class="col-6">Сборка ({{ assembly_percent }}%):</div>
<div class="col-6 text-end text-info" id="assembly-amount">+{{ assembly_amount|int }} руб.</div>
</div>
<div class="row mb-2">
<div class="col-6">Доставка ({{ delivery_percent }}%):</div>
<div class="col-6 text-end text-info" id="delivery-amount">+{{ delivery_amount|int }} руб.</div>
</div>
<hr>
<div class="row">
<div class="col-6 fs-5 fw-bold">ИТОГО:</div>
<div class="col-6 text-end fs-5 fw-bold" style="color: var(--steel-blue);" id="cart-total">{{ total|int }} руб.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Кнопка оформления заказа -->
<div class="text-end mt-4">
<form method="POST" action="{{ url_for('checkout') }}" id="checkoutForm">
<button type="submit" class="btn btn-success btn-lg"
onclick="return confirm('Оформить заказ?')">
<i class="fas fa-check"></i> Оформить заказ ({{ total|int }} руб.)
</button>
</form>
</div>
{% else %}
<!-- Пустая корзина -->
<div class="text-center py-5">
<i class="fas fa-shopping-cart fa-4x mb-3" style="color: var(--light-steel-blue);"></i>
<h4>Корзина пуста</h4>
<p class="text-muted mb-4">Добавьте товары в корзину, чтобы оформить заказ</p>
<a href="{{ url_for('catalog') }}" class="btn btn-primary btn-lg">
<i class="fas fa-arrow-left"></i> Перейти в каталог
</a>
</div>
{% endif %}
</div>
<!-- JavaScript -->
<script>
function isNumberKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
if (charCode == 8 || charCode == 46 || charCode == 9 || charCode == 13 ||
charCode == 37 || charCode == 38 || charCode == 39 || charCode == 40) {
return true;
}
if (charCode >= 48 && charCode <= 57) {
return true;
}
return false;
}
document.addEventListener('DOMContentLoaded', function() {
const assemblyPercent = 15;
const deliveryPercent = 10;
function updateAllTotals() {
let subtotal = 0;
document.querySelectorAll('.item-total').forEach(cell => {
const value = parseFloat(cell.textContent) || 0;
subtotal += value;
});
const assemblyAmount = Math.round(subtotal * assemblyPercent / 100);
const deliveryAmount = Math.round(subtotal * deliveryPercent / 100);
const total = subtotal + assemblyAmount + deliveryAmount;
const subtotalEl = document.getElementById('cart-subtotal');
const assemblyEl = document.getElementById('assembly-amount');
const deliveryEl = document.getElementById('delivery-amount');
const totalEl = document.getElementById('cart-total');
const checkoutBtn = document.querySelector('#checkoutForm button');
if (subtotalEl) subtotalEl.textContent = subtotal.toFixed(0) + ' руб.';
if (assemblyEl) assemblyEl.textContent = '+' + assemblyAmount.toFixed(0) + ' руб.';
if (deliveryEl) deliveryEl.textContent = '+' + deliveryAmount.toFixed(0) + ' руб.';
if (totalEl) totalEl.textContent = total.toFixed(0) + ' руб.';
if (checkoutBtn) {
checkoutBtn.innerHTML = `<i class="fas fa-check"></i> Оформить заказ (${total.toFixed(0)} руб.)`;
}
}
function updateQuantity(itemId, newQuantity) {
const input = document.getElementById(`quantity-${itemId}`);
const row = document.getElementById(`item-${itemId}`);
const price = parseFloat(row.dataset.price);
const maxStock = parseInt(row.dataset.max);
if (newQuantity < 1) newQuantity = 1;
if (newQuantity > maxStock) {
newQuantity = maxStock;
const error = document.getElementById(`error-${itemId}`);
if (error) {
error.style.display = 'block';
setTimeout(() => {
error.style.display = 'none';
}, 2000);
}
}
input.value = newQuantity;
const total = price * newQuantity;
const totalCell = row.querySelector('.item-total');
if (totalCell) totalCell.textContent = total.toFixed(0) + ' руб.';
fetch(`/update_cart/${itemId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `quantity=${newQuantity}`
})
.then(response => {
if (response.ok) {
updateAllTotals();
}
})
.catch(error => console.error('Error:', error));
}
// Обработчики для кнопок
document.querySelectorAll('.quantity-minus').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
const input = document.getElementById(`quantity-${itemId}`);
if (input) {
let currentValue = parseInt(input.value) || 1;
updateQuantity(itemId, currentValue - 1);
}
});
});
document.querySelectorAll('.quantity-plus').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
const input = document.getElementById(`quantity-${itemId}`);
if (input) {
let currentValue = parseInt(input.value) || 1;
updateQuantity(itemId, currentValue + 1);
}
});
});
document.querySelectorAll('.quantity-input').forEach(input => {
input.addEventListener('paste', function(e) {
e.preventDefault();
return false;
});
let timeoutId;
input.addEventListener('input', function() {
this.value = this.value.replace(/[^0-9]/g, '');
const itemId = this.dataset.itemId;
const row = document.getElementById(`item-${itemId}`);
if (!row) return;
const price = parseFloat(row.dataset.price);
let quantity = parseInt(this.value);
if (isNaN(quantity) || quantity < 1) {
quantity = 1;
this.value = 1;
}
const total = price * quantity;
const totalCell = row.querySelector('.item-total');
if (totalCell) totalCell.textContent = total.toFixed(0) + ' руб.';
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
updateQuantity(itemId, quantity);
}, 500);
});
});
document.querySelectorAll('.remove-item').forEach(btn => {
btn.addEventListener('click', function() {
const itemId = this.dataset.itemId;
const itemName = this.dataset.itemName;
if (confirm(`Удалить "${itemName}" из корзины?`)) {
fetch(`/remove_from_cart/${itemId}`, {
method: 'POST'
})
.then(() => {
location.reload();
});
}
});
});
updateAllTotals();
});
</script>
<style>
.page-title {
color: var(--steel-blue);
font-size: 2rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.table thead th {
background-color: var(--steel-blue);
color: white;
font-weight: 600;
border: none;
padding: 12px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.table tbody tr:hover {
background-color: rgba(176, 196, 222, 0.2);
}
.table tbody td {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #2c3e50;
vertical-align: middle;
}
.quantity-control {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
position: relative;
}
.quantity-btn {
width: 30px;
height: 30px;
border: 1px solid var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
color: var(--steel-blue);
font-size: 1.2rem;
font-weight: bold;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.quantity-btn:hover {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border-color: var(--steel-blue);
}
.quantity-input {
width: 60px;
height: 30px;
border: 2px solid var(--light-steel-blue);
border-radius: 6px;
text-align: center;
font-size: 1rem;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
padding: 0;
margin: 0;
-moz-appearance: textfield;
appearance: textfield;
}
.quantity-input:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.stock-error {
color: #dc3545;
font-size: 0.75rem;
position: absolute;
bottom: -20px;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background-color: white;
padding: 2px 8px;
border-radius: 12px;
border: 1px solid #dc3545;
z-index: 10;
}
.btn-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%);
border: none;
padding: 12px 30px;
font-size: 1.1rem;
}
.btn-success:hover {
background: linear-gradient(135deg, #218838 0%, #28a745 100%);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
border: none;
padding: 12px 30px;
font-size: 1.1rem;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(70, 130, 180, 0.3);
}
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.1);
}
@media (max-width: 768px) {
.table {
font-size: 0.9rem;
}
.quantity-control {
gap: 3px;
}
.quantity-btn {
width: 25px;
height: 25px;
font-size: 1rem;
}
.quantity-input {
width: 45px;
height: 25px;
font-size: 0.9rem;
}
}
</style>
{% endblock %}

564
templates/catalog.html Normal file
View file

@ -0,0 +1,564 @@
{% extends "base.html" %}
{% block title %}Каталог товаров{% endblock %}
{% block content %}
<!-- Заголовок с корзиной и кнопкой добавления -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">Каталог товаров</h2>
<div class="d-flex gap-2">
<a href="{{ url_for('add_product') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Добавить товар
</a>
<a href="{{ url_for('view_cart') }}" class="btn btn-primary">
<i class="fas fa-shopping-cart"></i> Корзина
{% if cart_count > 0 %}
<span class="cart-badge">{{ cart_count }}</span>
{% endif %}
</a>
</div>
</div>
<!-- Поиск и сортировка -->
<div class="filter-section mb-4">
<h4 class="filter-title">Поиск и сортировка</h4>
<form method="GET" action="{{ url_for('catalog') }}" id="filterForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="search" class="form-label">Поиск по названию или артикулу:</label>
<div class="input-group">
<input type="text" class="form-control" name="search" id="search"
placeholder="Введите название или артикул..."
value="{{ request.args.get('search', '') }}">
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i> Найти
</button>
</div>
</div>
<div class="col-md-3 mb-3">
<label for="sort" class="form-label">Сортировать по:</label>
<select class="form-select" name="sort" id="sort">
<option value="name_asc" {% if request.args.get('sort') == 'name_asc' or not request.args.get('sort') %}selected{% endif %}>Названию (А-Я)</option>
<option value="name_desc" {% if request.args.get('sort') == 'name_desc' %}selected{% endif %}>Названию (Я-А)</option>
<option value="price_asc" {% if request.args.get('sort') == 'price_asc' %}selected{% endif %}>Цене (сначала дешевые)</option>
<option value="price_desc" {% if request.args.get('sort') == 'price_desc' %}selected{% endif %}>Цене (сначала дорогие)</option>
<option value="stock_desc" {% if request.args.get('sort') == 'stock_desc' %}selected{% endif %}>Наличию (сначала много)</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label for="category" class="form-label">Категория:</label>
<select class="form-select" name="category" id="category">
<option value="all" {% if request.args.get('category') == 'all' or not request.args.get('category') %}selected{% endif %}>Все категории</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if request.args.get('category')|int == category.id %}selected{% endif %}>
{{ category.category_name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-12">
<a href="{{ url_for('catalog') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Сбросить все фильтры
</a>
</div>
</div>
</form>
</div>
<!-- Каталог товаров -->
{% if products %}
{% for product in products %}
<div class="product-card mb-3">
<div class="product-card-header">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="product-title">{{ product.product_name }}</h5>
<span class="product-article">Артикул: {{ product.article }}</span>
</div>
<a href="{{ url_for('edit_product', product_id=product.id) }}" class="btn-edit-icon" title="Редактировать товар">
<i class="fas fa-edit"></i>
</a>
</div>
</div>
<div class="product-card-body">
<div class="row">
<div class="col-md-8">
<div class="product-info">
<span class="info-label">Категория:</span>
<span class="info-value">{{ product.category_name }}</span>
</div>
<div class="product-info">
<span class="info-label">Материал:</span>
<span class="info-value">
{% if product.material_name %}
{{ product.material_name }}
{% else %}
Не указан
{% endif %}
</span>
</div>
<div class="product-info">
<span class="info-label">Время изготовления:</span>
<span class="info-value">{{ product.production_time_days or 1 }} дн.</span>
</div>
{% if product.description %}
<div class="product-info">
<span class="info-label">Описание:</span>
<span class="info-value">{{ product.description }}</span>
</div>
{% endif %}
</div>
<div class="col-md-4">
<div class="product-info">
<span class="info-label">В наличии:</span>
<span class="info-value">
{% if product.quantity_in_stock and product.quantity_in_stock > 0 %}
<span class="stock-badge">{{ product.quantity_in_stock }} ед.</span>
{% else %}
<span class="text-danger">Нет в наличии</span>
{% endif %}
</span>
</div>
<div class="product-price">
<span class="price-label">Цена:</span>
<span class="price-value">{{ product.min_price|int }} руб.</span>
</div>
</div>
</div>
<!-- Кнопки действий с выбором партнера -->
<div class="product-card-footer mt-3">
<form method="POST" action="{{ url_for('add_to_cart', product_id=product.id) }}" class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small mb-1">Количество:</label>
<input type="number" name="quantity" value="1" min="1"
{% if product.quantity_in_stock and product.quantity_in_stock > 0 %}max="{{ product.quantity_in_stock }}"{% endif %}
class="form-control quantity-input" style="width: 100%;"
onkeypress="return isNumberKey(event)"
oninput="this.value = this.value.replace(/[^0-9]/g, '')">
</div>
<div class="col-md-5">
<label class="form-label small mb-1">Для партнера:</label>
<select name="client_id" class="form-select" required>
<option value="">Выберите партнера</option>
{% for client in clients %}
<option value="{{ client.id }}">{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary w-100"
{% if not product.quantity_in_stock or product.quantity_in_stock == 0 %}disabled{% endif %}>
<i class="fas fa-cart-plus"></i> В корзину
</button>
</div>
</form>
<div class="mt-2">
<a href="{{ url_for('product_detail', product_id=product.id) }}" class="btn btn-info btn-sm w-100">
<i class="fas fa-eye"></i> Подробнее
</a>
</div>
</div>
</div>
</div>
{% endfor %}
<!-- Информация о результатах поиска -->
<div class="row mt-3">
<div class="col-12">
<p class="text-muted">
<i class="fas fa-info-circle"></i>
Найдено товаров: <strong>{{ products|length }}</strong>
{% if request.args.get('search') %}
по запросу "{{ request.args.get('search') }}"
{% endif %}
{% if request.args.get('category') and request.args.get('category') != 'all' %}
{% for category in categories %}
{% if category.id == request.args.get('category')|int %}
, категория: {{ category.category_name }}
{% endif %}
{% endfor %}
{% endif %}
</p>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
{% if request.args.get('search') or request.args.get('category') != 'all' %}
<strong>Ничего не найдено</strong> по вашему запросу.
<br><a href="{{ url_for('catalog') }}" class="alert-link">Сбросить все фильтры</a>
{% else %}
В каталоге нет товаров. <a href="{{ url_for('add_product') }}" class="alert-link">Добавьте первый товар</a>.
{% endif %}
</div>
{% endif %}
<!-- JavaScript -->
<script>
function isNumberKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
if (charCode == 8 || charCode == 46 || charCode == 9 || charCode == 13 ||
charCode == 37 || charCode == 38 || charCode == 39 || charCode == 40) {
return true;
}
if (charCode >= 48 && charCode <= 57) {
return true;
}
return false;
}
document.getElementById('sort').addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
document.getElementById('category').addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
let searchTimeout;
document.getElementById('search').addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.value.length >= 2 || this.value.length === 0) {
document.getElementById('filterForm').submit();
}
}, 800);
});
document.getElementById('search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('filterForm').submit();
}
});
document.querySelectorAll('.quantity-input').forEach(input => {
input.addEventListener('paste', function(e) {
e.preventDefault();
return false;
});
});
</script>
<style>
.page-title {
color: var(--steel-blue);
font-size: 1.8rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.d-flex.gap-2 {
gap: 10px;
}
.filter-section {
background-color: var(--alice-blue);
padding: 20px;
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
margin-bottom: 20px;
}
.filter-title {
color: var(--steel-blue);
margin-bottom: 20px;
font-size: 1.25rem;
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-label {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: var(--steel-blue);
font-weight: 500;
margin-bottom: 5px;
}
.form-control, .form-select {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: 1px solid var(--light-steel-blue);
border-radius: 8px;
height: 38px;
padding: 0 12px;
}
.form-select {
padding: 0 30px 0 12px;
background-position: right 10px center;
line-height: 38px;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.btn {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: none;
border-radius: 8px;
padding: 10px 20px;
transition: all 0.3s ease;
font-size: 1rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 38px;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
color: white;
}
.btn-primary:disabled {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
cursor: not-allowed;
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.btn-info {
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
font-size: 1rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 38px;
}
.btn-info:hover {
background: linear-gradient(135deg, #138496 0%, #17a2b8 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(23, 162, 184, 0.3);
color: white;
}
.btn-info.btn-sm {
height: auto;
padding: 8px 12px;
font-size: 0.9rem;
}
.cart-badge {
background-color: #dc3545;
color: white;
border-radius: 50%;
padding: 2px 6px;
font-size: 0.7rem;
margin-left: 5px;
min-width: 18px;
text-align: center;
}
.product-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.1);
transition: all 0.2s ease;
}
.product-card:hover {
box-shadow: 0 8px 16px rgba(70, 130, 180, 0.2);
border-color: var(--steel-blue);
}
.product-card-header {
border-bottom: 1px solid var(--light-steel-blue);
padding-bottom: 10px;
margin-bottom: 15px;
}
.product-title {
color: var(--steel-blue);
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 5px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.product-article {
color: #6c757d;
font-size: 0.9rem;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-edit-icon {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
transition: all 0.2s ease;
font-size: 1rem;
}
.btn-edit-icon:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
color: #2c3e50;
}
.product-info {
margin-bottom: 8px;
font-size: 1rem;
}
.info-label {
color: #6c757d;
font-weight: 500;
min-width: 100px;
display: inline-block;
}
.info-value {
color: #2c3e50;
font-weight: 500;
}
.product-price {
margin-top: 10px;
padding: 10px;
background-color: rgba(70, 130, 180, 0.05);
border-radius: 8px;
text-align: center;
}
.price-label {
color: #6c757d;
font-weight: 500;
margin-right: 10px;
}
.price-value {
color: var(--steel-blue);
font-size: 1.5rem;
font-weight: 700;
}
.product-card-footer {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--light-steel-blue);
}
.product-card-footer .form-control,
.product-card-footer .form-select {
height: 38px;
font-size: 0.95rem;
}
.product-card-footer .btn {
height: 38px;
padding: 0 15px;
}
.stock-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 0.85rem;
}
.text-danger {
color: #dc3545;
}
.text-muted {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
margin: 0;
}
.alert {
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border-left: 6px solid var(--steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-link {
color: var(--steel-blue);
font-weight: 600;
}
.input-group .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
height: 38px;
}
@media (max-width: 768px) {
.col-md-4, .col-md-8 {
width: 100%;
}
.product-price {
margin-top: 15px;
}
.btn {
width: 100%;
justify-content: center;
}
.d-flex.gap-2 {
flex-direction: column;
}
.d-flex.gap-2 .btn {
width: 100%;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,6 @@
<div class="header">
<div class="logo-container">
<img src="{{ url_for('static', filename='OfficeStyle.png') }}" alt="OfficeStyle" class="logo">
<h1 class="header-title">Офисная мебель</h1>
</div>
</div>

View file

@ -0,0 +1,244 @@
<div class="col-12">
<div class="partner-card">
<!-- Первая строка: Тип | Наименование -->
<div class="partner-header">
<div class="partner-name-line">
<span class="partner-type">
{% if partner.contractor_type_name %}
{{ partner.contractor_type_name }}
{% else %}
Тип не указан
{% endif %}
</span>
<span class="divider">|</span>
<span class="partner-name">{{ partner.name }}</span>
</div>
<div class="sales-info">
<span class="sales-amount">
{{ partner.total_quantity }} ед.
</span>
</div>
</div>
<!-- Информация о партнере -->
<div class="partner-info-row">
<span class="partner-label">Директор:</span>
<span class="partner-value">{{ partner.director or 'Не указан' }}</span>
</div>
<div class="partner-info-row">
<span class="partner-label">Телефон:</span>
<span class="partner-value">
{% if partner.phone %}
{{ partner.phone }}
{% else %}
Не указан
{% endif %}
</span>
</div>
<div class="partner-info-row">
<span class="partner-label">Рейтинг:</span>
<span class="partner-value">
<span class="badge rating-badge bg-{{ 'success' if partner.rating >= 4 else 'warning' if partner.rating >= 3 else 'danger' }}">
{{ partner.rating }}
</span>
</span>
</div>
<div class="partner-info-row">
<span class="partner-label">Общая сумма заказов:</span>
<span class="partner-value">
{{ "%.2f"|format(partner.total_sales) }} руб.
</span>
</div>
<!-- Кнопки действий внизу -->
<div class="partner-buttons-container">
<a href="{{ url_for('edit_client', client_id=partner.id) }}" class="btn btn-action btn-edit">
<i class="fas fa-user-edit"></i> Редактировать партнера
</a>
<a href="{{ url_for('order_history', client_id=partner.id) }}" class="btn btn-action btn-history">
<i class="fas fa-history"></i> История заказов
</a>
</div>
</div>
</div>
<style>
.partner-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.1);
transition: all 0.2s ease;
}
.partner-card:hover {
box-shadow: 0 8px 16px rgba(70, 130, 180, 0.2);
border-color: var(--steel-blue);
}
.partner-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid var(--light-steel-blue);
}
.partner-name-line {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.partner-type {
background-color: transparent;
color: #2c3e50;
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: 2px solid var(--light-steel-blue);
}
.divider {
color: var(--steel-blue);
font-weight: 600;
}
.partner-name {
color: #2c3e50;
font-size: 1.2rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.sales-info {
display: flex;
align-items: center;
}
.sales-amount {
color: var(--steel-blue);
font-weight: 600;
font-size: 0.9rem;
}
.partner-info-row {
display: flex;
margin-bottom: 8px;
padding: 5px 0;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.partner-label {
width: 140px;
color: #6c757d;
font-weight: 500;
}
.partner-value {
flex: 1;
color: #2c3e50;
font-weight: 500;
}
.badge.rating-badge {
padding: 5px 10px;
font-size: 0.85rem;
}
.badge.bg-success {
background: #28a745 !important;
color: white !important;
}
.badge.bg-warning {
background: #ffc107 !important;
color: #2c3e50 !important;
}
.badge.bg-danger {
background: #dc3545 !important;
color: white !important;
}
.partner-buttons-container {
display: flex;
gap: 10px;
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid var(--light-steel-blue);
}
.btn-action {
flex: 1;
text-align: center;
padding: 8px 12px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-edit {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
border: none;
}
.btn-edit:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.btn-history {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
border: none;
}
.btn-history:hover {
background: linear-gradient(135deg, #5a6268 0%, #6c757d 100%);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(108, 117, 125, 0.3);
}
@media (max-width: 768px) {
.partner-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.sales-info {
width: 100%;
justify-content: space-between;
}
.partner-info-row {
flex-direction: column;
}
.partner-label {
width: 100%;
margin-bottom: 3px;
}
.partner-buttons-container {
flex-direction: column;
}
}
</style>

396
templates/edit_client.html Normal file
View file

@ -0,0 +1,396 @@
{% extends "base.html" %}
{% block title %}Редактирование клиента{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h2 class="card-title mb-0">
<i class="fas fa-edit"></i> Редактирование клиента
</h2>
</div>
<div class="card-body">
<!-- Flash сообщения -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" id="clientForm">
<div class="row">
<!-- Основная информация -->
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3" style="color: var(--steel-blue);">Основная информация</h5>
<div class="mb-3">
<label for="name" class="form-label">Название компании</label>
<input type="text" class="form-control" id="name" name="name"
value="{{ client.name }}" required maxlength="255"
onkeypress="return blockNumbers(event)" onpaste="return blockNumbersPaste(event)">
</div>
<div class="mb-3">
<label for="director" class="form-label">Директор (ФИО)</label>
<input type="text" class="form-control" id="director" name="director"
value="{{ client.director or '' }}" maxlength="255"
onkeypress="return blockNumbers(event)" onpaste="return blockNumbersPaste(event)">
</div>
<div class="mb-3">
<label for="contractor_type_id" class="form-label">Тип клиента</label>
<select class="form-select" id="contractor_type_id" name="contractor_type_id" required>
<option value="">Выберите тип клиента</option>
{% for type in contractor_types %}
<option value="{{ type.id }}"
{% if type.id == client.contractor_type_id %}selected{% endif %}>
{{ type.type_name }}
</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="rating" class="form-label">Рейтинг</label>
<input type="number" class="form-control" id="rating" name="rating"
min="0" max="5" step="0.1" value="{{ client.rating or 0.0 }}">
<div class="form-text">От 0.0 до 5.0</div>
</div>
</div>
<!-- Контактная информация -->
<div class="col-md-6">
<h5 class="border-bottom pb-2 mb-3" style="color: var(--steel-blue);">Контактная информация</h5>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
value="{{ client.email }}" required maxlength="255">
</div>
<div class="mb-3">
<label for="phone" class="form-label">Телефон</label>
<input type="tel" class="form-control" id="phone" name="phone"
value="{{ client.phone or '' }}" maxlength="50"
onkeypress="return allowPhoneNumbers(event)">
</div>
<div class="mb-3">
<label for="inn" class="form-label">ИНН</label>
<input type="text" class="form-control" id="inn" name="inn"
value="{{ client.inn or '' }}" maxlength="20" pattern="\d{10,12}"
onkeypress="return allowOnlyNumbers(event)" onpaste="return allowNumbersPaste(event)">
<div class="form-text">10 или 12 цифр</div>
</div>
<div class="mb-3">
<label for="address" class="form-label">Юридический адрес</label>
<textarea class="form-control" id="address" name="address"
rows="3" maxlength="500">{{ client.address or '' }}</textarea>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="d-flex justify-content-between">
<a href="{{ url_for('index') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Назад к списку
</a>
<div>
<a href="{{ url_for('order_history', client_id=client.id) }}"
class="btn btn-info me-2">
<i class="fas fa-history"></i> История заказов
</a>
<button type="submit" class="btn btn-success">
<i class="fas fa-save"></i> Сохранить изменения
</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<style>
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control, .form-select {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.form-control:hover, .form-select:hover {
border-color: var(--steel-blue);
}
.btn-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.btn-success:hover {
background: linear-gradient(135deg, #218838 0%, #28a745 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(40, 167, 69, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.btn-info {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: white;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
transition: all 0.3s ease;
}
.btn-info:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.alert {
border-radius: 10px;
border: 1px solid var(--light-steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-success {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
color: #155724;
border-left: 6px solid #28a745;
}
.alert-danger {
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
color: #721c24;
border-left: 6px solid #dc3545;
}
.border-bottom {
border-color: var(--light-steel-blue) !important;
}
textarea.form-control {
resize: vertical;
}
</style>
{% block scripts %}
<script>
// Запрет на ввод цифр в текстовые поля (название компании, ФИО)
function blockNumbers(event) {
const key = event.key;
// Разрешаем: буквы, пробел, backspace, delete, tab, enter, стрелки
if (key === ' ' || key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
// Запрещаем цифры
if (key >= '0' && key <= '9') {
event.preventDefault();
showNotification('Цифры запрещены в этом поле');
return false;
}
// Разрешаем буквы (любые)
return true;
}
// Запрет на вставку цифр через Ctrl+V
function blockNumbersPaste(event) {
event.preventDefault();
const pastedText = (event.clipboardData || window.clipboardData).getData('text');
// Удаляем все цифры из вставляемого текста
const cleanedText = pastedText.replace(/[0-9]/g, '');
// Вставляем очищенный текст
const input = event.target;
const start = input.selectionStart;
const end = input.selectionEnd;
const currentValue = input.value;
const newValue = currentValue.substring(0, start) + cleanedText + currentValue.substring(end);
input.value = newValue;
// Возвращаем курсор в конец вставленного текста
input.selectionStart = input.selectionEnd = start + cleanedText.length;
if (pastedText.match(/[0-9]/)) {
showNotification('Цифры были удалены из вставленного текста');
}
}
// Разрешаем только цифры (для ИНН)
function allowOnlyNumbers(event) {
const key = event.key;
// Разрешаем: цифры, backspace, delete, tab, enter, стрелки
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
// Разрешаем только цифры
if (key >= '0' && key <= '9') {
return true;
}
event.preventDefault();
showNotification('Только цифры разрешены в этом поле');
return false;
}
// Разрешаем вставку только цифр (для ИНН)
function allowNumbersPaste(event) {
event.preventDefault();
const pastedText = (event.clipboardData || window.clipboardData).getData('text');
// Оставляем только цифры
const cleanedText = pastedText.replace(/[^0-9]/g, '');
// Вставляем очищенный текст
const input = event.target;
const start = input.selectionStart;
const end = input.selectionEnd;
const currentValue = input.value;
const newValue = currentValue.substring(0, start) + cleanedText + currentValue.substring(end);
input.value = newValue;
// Возвращаем курсор в конец вставленного текста
input.selectionStart = input.selectionEnd = start + cleanedText.length;
if (pastedText.match(/[^0-9]/)) {
showNotification('Были вставлены только цифры, остальные символы удалены');
}
}
// Для поля телефона разрешаем цифры и некоторые символы
function allowPhoneNumbers(event) {
const key = event.key;
// Разрешаем: цифры, +, -, (, ), пробел, backspace и т.д.
const allowedKeys = ['+', '-', '(', ')', ' '];
if (key === 'Backspace' || key === 'Delete' || key === 'Tab' ||
key === 'Enter' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'ArrowUp' || key === 'ArrowDown' || key === 'Home' || key === 'End') {
return true;
}
if (allowedKeys.includes(key)) {
return true;
}
if (key >= '0' && key <= '9') {
return true;
}
event.preventDefault();
return false;
}
// Показать уведомление
function showNotification(message) {
// Можно использовать alert или создать более красивое уведомление
console.log(message);
// Для простоты используем alert, но можно заменить на что-то более элегантное
// alert(message);
}
// Валидация формы
document.getElementById('clientForm').addEventListener('submit', function(e) {
const inn = document.getElementById('inn').value;
const name = document.getElementById('name').value;
const director = document.getElementById('director').value;
// Проверка на наличие цифр в названии компании (дополнительная защита)
if (name.match(/[0-9]/)) {
e.preventDefault();
alert('Название компании не должно содержать цифры');
return false;
}
// Проверка на наличие цифр в ФИО директора
if (director && director.match(/[0-9]/)) {
e.preventDefault();
alert('ФИО директора не должно содержать цифры');
return false;
}
if (inn && !/^\d{10,12}$/.test(inn)) {
e.preventDefault();
alert('ИНН должен содержать 10 или 12 цифр');
return false;
}
const rating = parseFloat(document.getElementById('rating').value);
if (rating < 0 || rating > 5) {
e.preventDefault();
alert('Рейтинг должен быть от 0.0 до 5.0');
return false;
}
const email = document.getElementById('email').value;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
e.preventDefault();
alert('Введите корректный email адрес');
return false;
}
});
</script>
{% endblock %}
{% endblock %}

279
templates/edit_product.html Normal file
View file

@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Редактирование товара{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h2 class="card-title mb-0">
<i class="fas fa-edit"></i> Редактирование товара
</h2>
</div>
<div class="card-body">
<!-- Информация о партнере (только для чтения) -->
{% if client %}
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle"></i> Редактирование товара партнера:
<strong>{{ client.name }}</strong>
<input type="hidden" name="client_id" value="{{ client.id }}">
</div>
{% endif %}
<!-- Flash сообщения -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" id="productForm">
<div class="row">
<div class="col-md-6 mb-3">
<label for="product_name" class="form-label">Наименование</label>
<input type="text" class="form-control" id="product_name" name="product_name"
value="{{ product.product_name }}" required maxlength="255"
onkeypress="return isLetterKey(event)"
oninput="this.value = this.value.replace(/[^а-яА-ЯёЁa-zA-Z\s]/g, '')">
<small class="form-text text-muted">Только буквы</small>
</div>
<div class="col-md-6 mb-3">
<label for="article" class="form-label">Артикул</label>
<input type="text" class="form-control" id="article" name="article"
value="{{ product.article }}" required maxlength="100" readonly>
<small class="form-text text-muted">Не изменяется</small>
</div>
<div class="col-md-6 mb-3">
<label for="category_id" class="form-label">Категория</label>
<select class="form-select" id="category_id" name="category_id" required>
<option value="">Выберите категорию</option>
{% for category in categories %}
<option value="{{ category.id }}" {% if category.id == product.category_id %}selected{% endif %}>
{{ category.category_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6 mb-3">
<label for="material_id" class="form-label">Материал</label>
<select class="form-select" id="material_id" name="material_id">
<option value="">Не выбран</option>
{% for material in materials %}
<option value="{{ material.id }}" {% if material.id == product.material_id %}selected{% endif %}>
{{ material.material_name }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12 mb-3">
<label for="description" class="form-label">Описание</label>
<textarea class="form-control" id="description" name="description" rows="3" maxlength="500">{{ product.description or '' }}</textarea>
</div>
<!-- Скрытое поле с client_id (неизменяемый) -->
<input type="hidden" name="client_id" value="{{ product.client_id }}">
<div class="col-md-6 mb-3">
<label for="production_time_days" class="form-label">Время изготовления (дней)</label>
<input type="number" class="form-control" id="production_time_days" name="production_time_days"
min="1" value="{{ product.production_time_days or 1 }}" required
onkeypress="return isNumberKey(event)">
<small class="form-text text-muted">Сколько дней нужно на производство</small>
</div>
<div class="col-md-6 mb-3">
<label for="min_price" class="form-label">Цена</label>
<input type="number" class="form-control" id="min_price" name="min_price"
min="0" step="0.01" required value="{{ product.min_price }}"
onkeypress="return isNumberKey(event)">
</div>
<div class="col-md-6 mb-3">
<label for="quantity_in_stock" class="form-label">Количество на складе</label>
<input type="number" class="form-control" id="quantity_in_stock" name="quantity_in_stock"
min="0" value="{{ product.quantity_in_stock or 0 }}"
onkeypress="return isNumberKey(event)">
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="d-flex justify-content-between">
<a href="{{ url_for('catalog') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Назад
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Сохранить изменения
</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
// Функция для проверки ввода только цифр
function isNumberKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) {
return false;
}
return true;
}
// Функция для проверки ввода только букв
function isLetterKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
// Разрешаем буквы, пробел, backspace, delete, tab, enter
if (charCode == 32 || charCode == 8 || charCode == 46 || charCode == 9 || charCode == 13) {
return true;
}
// Русские и английские буквы
if ((charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122) ||
(charCode >= 1040 && charCode <= 1103)) {
return true;
}
return false;
}
document.getElementById('productForm').addEventListener('submit', function(e) {
const price = parseFloat(document.getElementById('min_price').value);
if (price <= 0) {
e.preventDefault();
alert('Цена должна быть больше 0');
return false;
}
const prodDays = parseInt(document.getElementById('production_time_days').value);
if (prodDays < 1) {
e.preventDefault();
alert('Время изготовления должно быть не менее 1 дня');
return false;
}
// Проверяем наименование на наличие цифр
const productName = document.getElementById('product_name').value;
if (/\d/.test(productName)) {
e.preventDefault();
alert('Наименование не должно содержать цифры');
return false;
}
});
// Очищаем поле наименования от цифр при вводе
document.getElementById('product_name').addEventListener('input', function() {
this.value = this.value.replace(/[0-9]/g, '');
});
</script>
<style>
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control, .form-select {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.form-text {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #6c757d;
}
.alert {
border-radius: 10px;
border: 1px solid var(--light-steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-success {
background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%);
color: #155724;
border-left: 6px solid #28a745;
}
.alert-danger {
background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%);
color: #721c24;
border-left: 6px solid #dc3545;
}
.alert-info {
background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%);
color: #0c5460;
border-left: 6px solid #17a2b8;
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
/* Стиль для readonly поля */
#article[readonly] {
background-color: #f8f9fa;
cursor: not-allowed;
}
</style>
{% endblock %}

16
templates/home.html Normal file
View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Flask Tutorial</title>
</head>
<body>
{% extends "template.html" %}
{% block content %}
<h1> My First Try Using Flask </h1>
<p> Flask is Fun </p>
{% endblock %}
</body>
</html>

238
templates/index.html Normal file
View file

@ -0,0 +1,238 @@
{% extends "base.html" %}
{% block title %}Партнеры офисной мебели{% endblock %}
{% block content %}
<!-- Заголовок и кнопка добавления (в стиле заказов) -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">Партнеры офисной мебели</h2>
<a href="{{ url_for('add_client') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Новый партнер
</a>
</div>
<!-- Поиск и сортировка -->
<div class="filter-section mb-4">
<h4 class="filter-title">Поиск и сортировка</h4>
<form method="GET" action="{{ url_for('index') }}" id="filterForm">
<div class="row">
<div class="col-md-4 mb-3">
<label for="search" class="form-label">Поиск по названию или ИНН:</label>
<div class="input-group">
<input type="text" class="form-control" name="search" id="search"
placeholder="Введите название или ИНН..."
value="{{ request.args.get('search', '') }}">
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i> Найти
</button>
</div>
</div>
<div class="col-md-3 mb-3">
<label for="sort" class="form-label">Сортировать по:</label>
<select class="form-select" name="sort" id="sort">
<option value="name_asc" {% if request.args.get('sort') == 'name_asc' or not request.args.get('sort') %}selected{% endif %}>Названию (А-Я)</option>
<option value="name_desc" {% if request.args.get('sort') == 'name_desc' %}selected{% endif %}>Названию (Я-А)</option>
<option value="rating_desc" {% if request.args.get('sort') == 'rating_desc' %}selected{% endif %}>Рейтингу (сначала высокий)</option>
<option value="rating_asc" {% if request.args.get('sort') == 'rating_asc' %}selected{% endif %}>Рейтингу (сначала низкий)</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label for="type" class="form-label">Тип партнера:</label>
<select class="form-select" name="type" id="type">
<option value="all" {% if request.args.get('type') == 'all' or not request.args.get('type') %}selected{% endif %}>Все типы</option>
<option value="ЗАО" {% if request.args.get('type') == 'ЗАО' %}selected{% endif %}>ЗАО</option>
<option value="ОАО" {% if request.args.get('type') == 'ОАО' %}selected{% endif %}>ОАО</option>
<option value="ООО" {% if request.args.get('type') == 'ООО' %}selected{% endif %}>ООО</option>
<option value="ПАО" {% if request.args.get('type') == 'ПАО' %}selected{% endif %}>ПАО</option>
</select>
</div>
<div class="col-md-2 mb-3">
<label for="rating" class="form-label">Мин. рейтинг:</label>
<select class="form-select" name="rating" id="rating">
<option value="0" {% if request.args.get('rating') == '0' or not request.args.get('rating') %}selected{% endif %}>Любой</option>
<option value="4" {% if request.args.get('rating') == '4' %}selected{% endif %}>4+</option>
<option value="4.5" {% if request.args.get('rating') == '4.5' %}selected{% endif %}>4.5+</option>
<option value="4.8" {% if request.args.get('rating') == '4.8' %}selected{% endif %}>4.8+</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col-12">
<a href="{{ url_for('index') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Сбросить все фильтры
</a>
</div>
</div>
</form>
</div>
<!-- Список партнеров -->
{% if partners %}
<div class="row">
{% for partner in partners %}
{% include 'components/partner_card.html' %}
{% endfor %}
</div>
<!-- Информация о результатах поиска -->
<div class="row mt-3">
<div class="col-12">
<p class="text-muted">
<i class="fas fa-info-circle"></i>
Найдено партнеров: <strong>{{ partners|length }}</strong>
{% if request.args.get('search') %}
по запросу "{{ request.args.get('search') }}"
{% endif %}
{% if request.args.get('type') and request.args.get('type') != 'all' %}
, тип: {{ request.args.get('type') }}
{% endif %}
{% if request.args.get('rating') and request.args.get('rating') != '0' %}
, рейтинг ≥ {{ request.args.get('rating') }}
{% endif %}
</p>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
{% if request.args.get('search') or request.args.get('type') != 'all' or request.args.get('rating') != '0' %}
<strong>Ничего не найдено</strong> по вашему запросу.
<br><a href="{{ url_for('index') }}" class="alert-link">Сбросить все фильтры</a>
{% else %}
Нет данных о партнере. <a href="{{ url_for('add_client') }}" class="alert-link">Добавьте первого партнера</a>.
{% endif %}
</div>
{% endif %}
<!-- JavaScript для автоматической сортировки -->
<script>
document.getElementById('sort').addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
document.getElementById('type').addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
document.getElementById('rating').addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
let searchTimeout;
document.getElementById('search').addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (this.value.length >= 2 || this.value.length === 0) {
document.getElementById('filterForm').submit();
}
}, 800);
});
document.getElementById('search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('filterForm').submit();
}
});
</script>
<style>
.page-title {
color: var(--steel-blue);
font-size: 1.8rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.filter-section {
background-color: var(--alice-blue);
padding: 20px;
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
margin-bottom: 20px;
}
.filter-title {
color: var(--steel-blue);
margin-bottom: 20px;
font-size: 1.25rem;
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-label {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: var(--steel-blue);
font-weight: 500;
}
.form-control, .form-select {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: 1px solid var(--light-steel-blue);
border-radius: 8px;
}
.form-control:focus, .form-select:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
}
.btn {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: none;
border-radius: 8px;
padding: 10px 20px;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
color: #2c3e50;
}
.input-group .btn {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
}
.text-muted {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert {
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border-left: 6px solid var(--steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-link {
color: var(--steel-blue);
font-weight: 600;
}
</style>
{% endblock %}

266
templates/materials.html Normal file
View file

@ -0,0 +1,266 @@
{% extends "base.html" %}
{% block title %}Материалы{% endblock %}
{% block content %}
<!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">Материалы</h2>
<span class="badge" style="background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%); color: white; padding: 8px 15px; border-radius: 20px; font-size: 1rem;">
<i class="fas fa-cube me-1"></i> Всего: {{ materials|length }}
</span>
</div>
<!-- Список материалов -->
{% if materials %}
<div class="row">
{% for material in materials %}
<div class="col-md-4 mb-3">
<div class="material-card">
<div class="material-card-header">
<h5 class="material-title">
<i class="fas fa-cube"></i> {{ material.material_name }}
</h5>
</div>
<div class="material-card-body">
{% if material.description %}
<p class="material-description">{{ material.description }}</p>
{% endif %}
<div class="material-properties">
{% if material.category %}
<div class="property-item">
<span class="property-label">Категория:</span>
<span class="property-value">{{ material.category }}</span>
</div>
{% endif %}
{% if material.color %}
<div class="property-item">
<span class="property-label">Цвет:</span>
<span class="property-value">{{ material.color }}</span>
</div>
{% endif %}
{% if material.density %}
<div class="property-item">
<span class="property-label">Плотность:</span>
<span class="property-value">{{ material.density }} кг/м³</span>
</div>
{% endif %}
{% if material.thickness %}
<div class="property-item">
<span class="property-label">Толщина:</span>
<span class="property-value">{{ material.thickness }} мм</span>
</div>
{% endif %}
{% if material.supplier %}
<div class="property-item">
<span class="property-label">Поставщик:</span>
<span class="property-value">{{ material.supplier }}</span>
</div>
{% endif %}
{% if material.country %}
<div class="property-item">
<span class="property-label">Страна:</span>
<span class="property-value">{{ material.country }}</span>
</div>
{% endif %}
{% if material.wear_class %}
<div class="property-item">
<span class="property-label">Класс износа:</span>
<span class="property-value">{{ material.wear_class }}</span>
</div>
{% endif %}
</div>
<div class="material-price-stock">
{% if material.price_per_unit %}
<div class="price-item">
<span class="price-label">Цена:</span>
<span class="price-value">{{ material.price_per_unit|int }} руб./{{ material.unit or 'шт' }}</span>
</div>
{% endif %}
<div class="stock-item">
<span class="stock-label">В наличии:</span>
<span class="stock-value {% if material.in_stock and material.in_stock > 0 %}text-success{% else %}text-danger{% endif %}">
{{ material.in_stock or 0 }} {{ material.unit or 'шт' }}
</span>
</div>
</div>
<div class="material-footer">
<small class="text-muted">
<i class="far fa-calendar-alt"></i> Добавлено: {{ material.created_at[:10] if material.created_at else '-' }}
</small>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info text-center py-5">
<i class="fas fa-cube fa-3x mb-3" style="color: var(--light-steel-blue);"></i>
<h4 class="mb-3">Нет материалов</h4>
<p class="mb-0">В базе данных пока нет материалов. Добавьте материалы через импорт данных.</p>
</div>
{% endif %}
<style>
.page-title {
color: var(--steel-blue);
font-size: 1.8rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.material-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
padding: 15px;
height: 100%;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.1);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
}
.material-card:hover {
box-shadow: 0 8px 16px rgba(70, 130, 180, 0.2);
border-color: var(--steel-blue);
transform: translateY(-2px);
}
.material-card-header {
border-bottom: 1px solid var(--light-steel-blue);
padding-bottom: 10px;
margin-bottom: 10px;
}
.material-title {
color: var(--steel-blue);
font-size: 1.2rem;
font-weight: 600;
margin: 0;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.material-title i {
margin-right: 8px;
color: var(--light-steel-blue);
}
.material-description {
color: #2c3e50;
margin-bottom: 12px;
font-size: 0.95rem;
line-height: 1.5;
padding-bottom: 8px;
border-bottom: 1px dashed var(--light-steel-blue);
}
.material-properties {
margin-bottom: 12px;
}
.property-item {
margin-bottom: 5px;
font-size: 0.9rem;
display: flex;
justify-content: space-between;
}
.property-label {
color: #6c757d;
font-weight: 500;
}
.property-value {
color: #2c3e50;
font-weight: 500;
}
.material-price-stock {
background-color: rgba(70, 130, 180, 0.1);
border-radius: 8px;
padding: 10px;
margin-top: 10px;
margin-bottom: 10px;
}
.price-item, .stock-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.price-label, .stock-label {
color: #6c757d;
font-weight: 500;
}
.price-value {
color: var(--steel-blue);
font-weight: 700;
font-size: 1.1rem;
}
.stock-value {
font-weight: 600;
}
.text-success {
color: #28a745 !important;
}
.text-danger {
color: #dc3545 !important;
}
.material-footer {
margin-top: auto;
padding-top: 8px;
border-top: 1px solid var(--light-steel-blue);
font-size: 0.85rem;
}
.alert-info {
border-radius: 12px;
border: 2px dashed var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
color: #2c3e50;
}
.alert-info i {
color: var(--steel-blue);
}
.alert-info h4 {
color: var(--steel-blue);
font-weight: 600;
}
.badge {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
@media (max-width: 768px) {
.col-md-4 {
width: 100%;
}
.property-item {
flex-direction: column;
align-items: flex-start;
}
}
</style>
{% endblock %}

View file

@ -0,0 +1,465 @@
{% extends "base.html" %}
{% block title %}История заказов - {{ client.name }}{% endblock %}
{% block content %}
<div class="sales-history-container">
<!-- Заголовок с кнопкой назад -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<a href="javascript:history.back()" class="btn btn-back me-3">
<i class="fas fa-arrow-left"></i> Назад
</a>
<h2 class="page-title">История заказов - {{ client.name }}</h2>
</div>
</div>
<!-- Информация о клиенте -->
<div class="partner-info-card mb-4">
<div class="partner-header">
<div class="partner-name-line">
<span class="partner-type">
{% if client.contractor_type_name %}
{{ client.contractor_type_name }}
{% else %}
Тип не указан
{% endif %}
</span>
<span class="divider">|</span>
<span class="partner-name">{{ client.name }}</span>
</div>
</div>
<div class="partner-details">
<div class="row">
<div class="col-md-6">
<div class="info-item">
<span class="info-label">Директор:</span>
<span class="info-value">{{ client.director or 'Не указан' }}</span>
</div>
<div class="info-item">
<span class="info-label">Телефон:</span>
<span class="info-value">{{ client.phone or 'Не указан' }}</span>
</div>
</div>
<div class="col-md-6">
<div class="info-item">
<span class="info-label">Email:</span>
<span class="info-value">{{ client.email or 'Не указан' }}</span>
</div>
<div class="info-item">
<span class="info-label">ИНН:</span>
<span class="info-value">{{ client.inn or 'Не указан' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Общая статистика -->
<div class="stats-card mb-4">
<h4 class="stats-title">Общая статистика</h4>
<div class="row">
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number">{{ total_stats.total_orders or 0 }}</div>
<div class="stat-label">Всего заказов</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number">{{ total_stats.total_quantity or 0 }}</div>
<div class="stat-label">Общее количество</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number">{{ "{:,.2f}".format(total_stats.total_amount or 0) }} руб.</div>
<div class="stat-label">Общая сумма</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="stat-number">{{ total_stats.completed_orders or 0 }}</div>
<div class="stat-label">Выполненных</div>
</div>
</div>
</div>
</div>
<!-- Таблица истории заказов -->
<div class="sales-table-card">
<h4 class="table-title">Детализация заказов</h4>
{% if orders %}
<div class="accordion" id="ordersAccordion">
{% for order in orders %}
<div class="accordion-item mb-3 border rounded">
<h2 class="accordion-header" id="heading{{ order.id }}">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse{{ order.id }}" aria-expanded="false" aria-controls="collapse{{ order.id }}">
<div class="row w-100">
<div class="col-md-3">
<strong>Заказ #{{ order.id }}</strong>
</div>
<div class="col-md-3">
{{ order.order_date[:10] }}
</div>
<div class="col-md-3">
{{ order['items']|length }} позиций
</div>
<div class="col-md-3 text-end">
<span class="badge {% if order.status == 'Выполнен' %}bg-success{% elif order.status == 'Отменен' %}bg-danger{% elif order.status == 'В обработке' %}bg-warning{% else %}bg-secondary{% endif %} me-2">
{{ order.status }}
</span>
<strong>{{ "{:,.2f}".format(order.total_amount) }} руб.</strong>
</div>
</div>
</button>
</h2>
<div id="collapse{{ order.id }}" class="accordion-collapse collapse" aria-labelledby="heading{{ order.id }}" data-bs-parent="#ordersAccordion">
<div class="accordion-body">
<!-- Детальная информация о заказе -->
<div class="row mb-3">
<div class="col-md-6">
<p><strong>Дата заказа:</strong> {{ order.order_date[:10] }}</p>
<p><strong>Срок изготовления:</strong> {{ order.production_days or 1 }} дн.</p>
</div>
<div class="col-md-6">
<p><strong>Дата готовности:</strong> {{ order.production_end_date[:10] if order.production_end_date else '-' }}</p>
</div>
</div>
<!-- Таблица с товарами -->
<h5>Состав заказа:</h5>
<div class="table-responsive">
<table class="table table-sm table-bordered">
<thead class="table-light">
<tr>
<th>Товар</th>
<th>Артикул</th>
<th>Количество</th>
<th>Цена за ед.</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order['items'] %}
{% set item_sum = item['price_at_moment'] * item['quantity'] %}
<tr>
<td>{{ item['product_name'] }}</td>
<td>{{ item['article'] }}</td>
<td>{{ "{:,}".format(item['quantity']) }} ед.</td>
<td>{{ "{:,.2f}".format(item['price_at_moment']) }} руб.</td>
<td>{{ "{:,.2f}".format(item_sum) }} руб.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Расчет стоимости с учетом сборки и доставки -->
<div class="row mt-3">
<div class="col-md-8 offset-md-4">
<div class="calculation-details p-3" style="background-color: #f8f9fa; border-radius: 8px;">
<div class="d-flex justify-content-between mb-2">
<span>Базовая сумма товаров:</span>
<strong>{{ "{:,.2f}".format(order.base_amount) }} руб.</strong>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Сборка ({{ order.assembly_percent }}%):</span>
<span class="text-primary">+{{ "{:,.2f}".format(order.assembly_cost) }} руб.</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span>Доставка ({{ order.delivery_percent }}%):</span>
<span class="text-primary">+{{ "{:,.2f}".format(order.delivery_cost) }} руб.</span>
</div>
<div class="d-flex justify-content-between pt-2 border-top">
<span class="fw-bold">ИТОГОВАЯ СУММА ЗАКАЗА:</span>
<span class="fw-bold fs-5" style="color: var(--steel-blue);">{{ "{:,.2f}".format(order.total_amount) }} руб.</span>
</div>
</div>
</div>
</div>
<!-- Форма изменения статуса заказа -->
<div class="row mt-3">
<div class="col-md-6">
<form method="POST" action="{{ url_for('update_order_status', order_id=order.id) }}" class="d-flex align-items-center gap-2">
<label class="mb-0"><strong>Статус заказа:</strong></label>
<select name="status" class="form-select form-select-sm" style="width: 150px;" onchange="this.form.submit()">
<option value="Новый" {% if order.status == 'Новый' %}selected{% endif %}>Новый</option>
<option value="В обработке" {% if order.status == 'В обработке' %}selected{% endif %}>В обработке</option>
<option value="Выполнен" {% if order.status == 'Выполнен' %}selected{% endif %}>Выполнен</option>
<option value="Отменен" {% if order.status == 'Отменен' %}selected{% endif %}>Отменен</option>
</select>
</form>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i> Нет данных о заказах
</div>
{% endif %}
</div>
</div>
<style>
.btn-back {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
color: #2c3e50;
transition: all 0.3s ease;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.btn-back:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
color: #2c3e50;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
border: none;
border-radius: 8px;
padding: 10px 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: white;
transition: all 0.3s ease;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.page-title {
color: var(--steel-blue);
font-size: 1.8rem;
font-weight: 600;
display: inline-block;
margin: 0;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.partner-info-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.partner-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid var(--light-steel-blue);
}
.partner-name-line {
display: flex;
align-items: center;
gap: 10px;
}
.partner-type {
background-color: transparent;
color: #2c3e50 !important;
padding: 5px 15px;
border-radius: 20px;
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: 2px solid var(--light-steel-blue);
}
.divider {
color: var(--steel-blue);
font-weight: 600;
}
.partner-name {
color: #2c3e50;
font-size: 1.3rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.info-item {
margin-bottom: 10px;
padding: 8px 15px;
background-color: rgba(255, 255, 255, 0.5);
border-radius: 8px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.info-label {
color: var(--steel-blue);
font-weight: 600;
margin-right: 10px;
}
.info-value {
color: #2c3e50;
}
.stats-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.stats-title, .table-title {
color: var(--steel-blue);
font-size: 1.3rem;
font-weight: 600;
margin-bottom: 20px;
border-bottom: 2px solid var(--light-steel-blue);
padding-bottom: 10px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.stat-item {
text-align: center;
padding: 15px;
background: linear-gradient(135deg, white 0%, #f8f9fa 100%);
border: 2px solid var(--light-steel-blue);
border-radius: 12px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.15);
transition: all 0.3s ease;
}
.stat-item:hover {
transform: translateY(-3px);
box-shadow: 0 8px 15px rgba(70, 130, 180, 0.25);
border-color: var(--steel-blue);
}
.stat-number {
color: #2c3e50 !important;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
margin-bottom: 5px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.sales-table-card {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border: 1px solid var(--light-steel-blue);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.1);
}
.accordion-item {
border: 1px solid var(--light-steel-blue);
border-radius: 10px !important;
overflow: hidden;
}
.accordion-button {
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
color: #2c3e50;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
padding: 15px 20px;
}
.accordion-button:not(.collapsed) {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
}
.accordion-button:focus {
box-shadow: none;
border-color: var(--steel-blue);
}
.accordion-body {
background-color: white;
padding: 20px;
}
.badge {
padding: 6px 12px;
font-size: 0.85rem;
border-radius: 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
font-weight: 500;
}
.badge.bg-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%) !important;
color: white !important;
}
.badge.bg-warning {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%) !important;
color: #2c3e50 !important;
}
.badge.bg-danger {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
color: white !important;
}
.badge.bg-secondary {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%) !important;
color: white !important;
}
.table-sm td, .table-sm th {
padding: 8px;
}
.calculation-details {
background-color: #f8f9fa;
border: 1px solid var(--light-steel-blue);
border-radius: 8px;
}
.text-primary {
color: var(--steel-blue) !important;
}
small {
font-size: 0.8rem;
color: #6c757d;
}
@media (max-width: 768px) {
.page-title {
font-size: 1.4rem;
}
.stat-number {
font-size: 1.6rem;
}
.accordion-button .row > div {
margin-bottom: 5px;
text-align: left !important;
}
}
</style>
{% endblock %}

316
templates/orders_list.html Normal file
View file

@ -0,0 +1,316 @@
{% extends "base.html" %}
{% block title %}Список заказов{% endblock %}
{% block content %}
<!-- Заголовок -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">Список заказов</h2>
</div>
<!-- Статистика -->
<div class="row mb-4">
<div class="col-md-4">
<div class="card text-white bg-primary mb-3">
<div class="card-body">
<h5 class="card-title">Всего заказов</h5>
<p class="card-text display-6">{{ total_stats.total_orders or 0 }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card text-white bg-success mb-3">
<div class="card-body">
<h5 class="card-title">Всего единиц</h5>
<p class="card-text display-6">{{ "{:,}".format(total_stats.total_quantity or 0) }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<!-- ИСПРАВЛЕНО: цвет #4682B4 (Стальной синий) и белый текст -->
<div class="card text-white mb-3" style="background-color: #4682B4;">
<div class="card-body">
<h5 class="card-title">Общая сумма</h5>
<p class="card-text display-6">{{ "{:,.2f}".format(total_stats.total_amount or 0) }} руб.</p>
</div>
</div>
</div>
</div>
<!-- Таблица заказов -->
{% if orders %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Дата</th>
<th>Клиент</th>
<th>ИНН</th>
<th>Состав заказа</th>
<th>Всего единиц</th>
<th>Наценка (25%)</th>
<th>Итого</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for order in orders %}
{% set markup = order.assembly_cost + order.delivery_cost %}
<tr>
<td>{{ order.id }}</td>
<td>{{ order.order_date[:10] if order.order_date else '-' }}</td>
<td>
{% if order.client_id %}
<a href="{{ url_for('order_history', client_id=order.client_id) }}">
{{ order.client_name or 'Неизвестный клиент' }}
</a>
{% else %}
{{ order.client_name or 'Общий заказ' }}
{% endif %}
</td>
<td>{{ order.client_inn or '-' }}</td>
<td>
{% if order['items'] and order['items']|length > 0 %}
<button class="btn btn-sm btn-info" type="button"
data-bs-toggle="collapse"
data-bs-target="#items-{{ order.id }}"
aria-expanded="false">
<i class="fas fa-eye"></i> {{ order['items']|length }} товаров
</button>
<div class="collapse mt-2" id="items-{{ order.id }}">
<div class="card card-body p-2">
<table class="table table-sm table-bordered mb-0">
<thead>
<tr>
<th>Товар</th>
<th>Артикул</th>
<th>Кол-во</th>
<th>Цена за ед.</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order['items'] %}
{% set item_sum = item.price_at_moment * item.quantity %}
<tr>
<td>{{ item.product_name }}</td>
<td>{{ item.article }}</td>
<td>{{ "{:,}".format(item.quantity) }} ед.</td>
<td>{{ "{:,.2f}".format(item.price_at_moment) }} руб.</td>
<td>{{ "{:,.2f}".format(item_sum) }} руб.</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-info">
<tr>
<td colspan="4" class="text-end"><strong>Базовая сумма:</strong></td>
<td><strong>{{ "{:,.2f}".format(order.base_amount) }} руб.</strong></td>
</tr>
<tr>
<td colspan="4" class="text-end">Сборка ({{ order.assembly_percent }}%):</td>
<td>+{{ "{:,.2f}".format(order.assembly_cost) }} руб.</td>
</tr>
<tr>
<td colspan="4" class="text-end">Доставка ({{ order.delivery_percent }}%):</td>
<td>+{{ "{:,.2f}".format(order.delivery_cost) }} руб.</td>
</tr>
<tr class="table-success">
<td colspan="4" class="text-end"><strong>ИТОГО:</strong></td>
<td><strong>{{ "{:,.2f}".format(order.total_amount) }} руб.</strong></td>
</tr>
</tfoot>
</table>
</div>
</div>
{% else %}
<span class="text-muted">Нет товаров</span>
{% endif %}
</td>
<td>{{ "{:,}".format(order.total_items or 0) }} ед.</td>
<td class="text-info"><strong>+{{ "{:,.2f}".format(markup) }}</strong> руб.</td>
<td><strong>{{ "{:,.2f}".format(order.total_amount) }}</strong> руб.</td>
<td>
<span class="badge {% if order.status == 'Выполнен' %}bg-success{% elif order.status == 'Отменен' %}bg-danger{% elif order.status == 'В обработке' %}bg-warning{% else %}bg-secondary{% endif %}">
{{ order.status }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
Нет данных о заказах. <a href="{{ url_for('index') }}" class="alert-link">Вернуться на главную</a>.
</div>
{% endif %}
<style>
.page-title {
color: var(--steel-blue);
font-size: 1.8rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.table {
background-color: white;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.table thead th {
background-color: var(--steel-blue);
color: white;
font-weight: 600;
border: none;
padding: 12px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
vertical-align: middle;
}
.table tbody tr:hover {
background-color: rgba(176, 196, 222, 0.2);
}
.table tbody td {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
color: #2c3e50;
vertical-align: middle;
padding: 12px;
}
.table a {
color: var(--steel-blue);
text-decoration: none;
font-weight: 500;
}
.table a:hover {
text-decoration: underline;
}
.btn-info {
background: linear-gradient(135deg, #17a2b8 0%, #138496 100%);
color: white;
border: none;
font-size: 0.85rem;
padding: 4px 8px;
transition: all 0.2s ease;
}
.btn-info:hover {
background: linear-gradient(135deg, #138496 0%, #17a2b8 100%);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(23, 162, 184, 0.3);
}
.badge {
padding: 6px 10px;
font-size: 0.8rem;
border-radius: 20px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
font-weight: 500;
}
.badge.bg-success {
background: linear-gradient(135deg, #28a745 0%, #218838 100%) !important;
color: white !important;
}
.badge.bg-warning {
background: linear-gradient(135deg, #ffc107 0%, #e0a800 100%) !important;
color: #2c3e50 !important;
}
.badge.bg-danger {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
color: white !important;
}
.badge.bg-secondary {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%) !important;
color: white !important;
}
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
}
/* ИСПРАВЛЕНО: явно задаем цвет для карточки Общая сумма */
.bg-primary {
background-color: var(--steel-blue) !important;
}
.bg-success {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
}
.display-6 {
font-size: 2rem;
font-weight: 300;
line-height: 1.2;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert {
border-radius: 12px;
border: 1px solid var(--light-steel-blue);
background: linear-gradient(135deg, var(--alice-blue) 0%, #E6F2FF 100%);
border-left: 6px solid var(--steel-blue);
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.alert-link {
color: var(--steel-blue);
font-weight: 600;
}
.collapse .card {
border: 1px solid var(--light-steel-blue);
background-color: #f8f9fa;
margin-top: 10px;
}
.table-sm {
font-size: 0.85rem;
margin-bottom: 0;
}
.table-sm th {
background-color: var(--light-steel-blue);
color: #2c3e50;
font-weight: 600;
padding: 6px;
}
.table-sm td {
padding: 6px;
}
.table-info {
background-color: #d1ecf1 !important;
}
.table-success {
background-color: #d4edda !important;
}
.text-end {
text-align: right;
}
.text-info {
color: #17a2b8 !important;
}
/* Убираем лишний класс bg-warning */
.bg-warning {
background-color: transparent !important;
}
</style>
{% endblock %}

View file

@ -0,0 +1,427 @@
{% extends "base.html" %}
{% block title %}{{ product.product_name }} - детальная информация{% endblock %}
{% block content %}
<div class="container">
<!-- Навигация -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="page-title">{{ product.product_name }}</h2>
<div>
<a href="{{ url_for('edit_product', product_id=product.id) }}" class="btn btn-primary me-2">
<i class="fas fa-edit"></i> Редактировать
</a>
<a href="{{ url_for('catalog') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Назад в каталог
</a>
</div>
</div>
<div class="row">
<!-- Левая колонка - основная информация -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Основная информация</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<td style="width: 40%;"><strong>Артикул:</strong></td>
<td>{{ product.article }}</td>
</tr>
<tr>
<td><strong>Категория:</strong></td>
<td>{{ product.category_name }}</td>
</tr>
<tr>
<td><strong>Материал:</strong></td>
<td>
{% if product.material_name %}
{{ product.material_name }}
{% else %}
<span class="text-muted">Не указан</span>
{% endif %}
</td>
</tr>
{% if product.description %}
<tr>
<td><strong>Описание:</strong></td>
<td>{{ product.description }}</td>
</tr>
{% endif %}
<tr>
<td><strong>Базовая цена:</strong></td>
<td><span class="fw-bold fs-4" style="color: var(--steel-blue);">{{ product.min_price|int }} руб.</span></td>
</tr>
<tr>
<td><strong>В наличии:</strong></td>
<td>
{% if product.quantity_in_stock and product.quantity_in_stock > 0 %}
<span class="badge bg-success" style="font-size: 1rem;">{{ product.quantity_in_stock }} шт.</span>
{% else %}
<span class="badge bg-danger">Нет в наличии</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<!-- Информация о материале -->
{% if material %}
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);">
<h5 class="mb-0"><i class="fas fa-cube"></i> Информация о материале</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<td style="width: 40%;"><strong>Материал:</strong></td>
<td>{{ material.material_name }}</td>
</tr>
{% if material.color %}
<tr>
<td><strong>Цвет:</strong></td>
<td>{{ material.color }}</td>
</tr>
{% endif %}
{% if material.supplier %}
<tr>
<td><strong>Поставщик:</strong></td>
<td>{{ material.supplier }}</td>
</tr>
{% endif %}
<tr>
<td><strong>Цена за ед.:</strong></td>
<td>{{ material.price_per_unit|int }} руб./{{ material.unit }}</td>
</tr>
<tr>
<td><strong>На складе:</strong></td>
<td>
{% if material.in_stock and material.in_stock > 0 %}
<span class="badge bg-success">{{ material.in_stock }} {{ material.unit }}</span>
{% else %}
<span class="badge bg-danger">Нет</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
{% endif %}
</div>
<!-- Правая колонка - производство -->
<div class="col-md-6">
<!-- Производственные характеристики -->
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);">
<h5 class="mb-0"><i class="fas fa-cogs"></i> Производственные характеристики</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<td style="width: 50%;"><strong>Время изготовления:</strong></td>
<td>
{% if product.production_time_days %}
{{ product.production_time_days }} дн.
{% else %}
{{ material.production_time_days if material else 1 }} дн. (по умолчанию)
{% endif %}
</td>
</tr>
<tr>
<td><strong>Производительность:</strong></td>
<td>
{% if product.daily_capacity %}
{{ product.daily_capacity }} шт/день
{% else %}
{{ material.daily_capacity if material else 10 }} шт/день (по умолчанию)
{% endif %}
</td>
</tr>
<tr>
<td><strong>Себестоимость:</strong></td>
<td>
{% if product.cost_price %}
{{ product.cost_price|int }} руб.
{% elif material and material.price_per_unit %}
{{ (material.price_per_unit * 1.3)|int }} руб. (расчетная)
{% else %}
<span class="text-muted">Не указана</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Прибыль с единицы:</strong></td>
<td>
{% set cost = product.cost_price or (material.price_per_unit * 1.3 if material else 0) %}
{% if cost and cost > 0 %}
<span class="fw-bold text-success">{{ (product.min_price - cost)|int }} руб.</span>
<small class="text-muted">({{ ((product.min_price - cost) / product.min_price * 100)|round(1) }}%)</small>
{% else %}
<span class="text-muted">—</span>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<!-- Расчет стоимости с фиксированными процентами -->
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, var(--steel-blue) 0%, var(--light-steel-blue) 100%); color: white;">
<h5 class="mb-0"><i class="fas fa-truck"></i> Расчет стоимости со сборкой и доставкой</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<label for="quantity" class="form-label">Количество</label>
<input type="number" class="form-control" id="quantity" value="1" min="1" max="{{ product.quantity_in_stock or 100 }}"
onkeypress="return isNumberKey(event)"
oninput="this.value = this.value.replace(/[^0-9]/g, '')">
</div>
</div>
<!-- Результаты расчета -->
<div class="table-responsive mt-3">
<table class="table table-bordered">
<tr>
<th style="width: 50%;">Базовая стоимость:</th>
<td class="text-end fw-bold" id="baseCost">0 руб.</td>
</tr>
<tr>
<th>Стоимость сборки (15%):</th>
<td class="text-end" id="assemblyCost">0 руб.</td>
</tr>
<tr>
<th>Стоимость доставки (10%):</th>
<td class="text-end" id="deliveryCost">0 руб.</td>
</tr>
<tr class="table-info">
<th><span class="fs-5">ИТОГОВАЯ СТОИМОСТЬ:</span></th>
<td class="text-end"><span class="fw-bold fs-4" style="color: var(--steel-blue);" id="totalCost">0 руб.</span></td>
</tr>
</table>
</div>
</div>
</div>
<!-- Статистика продаж -->
<div class="card mb-4">
<div class="card-header" style="background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);">
<h5 class="mb-0"><i class="fas fa-chart-bar"></i> Статистика продаж</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-4">
<div class="p-3">
<div class="h3 mb-1 fw-bold" style="color: var(--steel-blue);">{{ orders_stats.total_orders or 0 }}</div>
<small class="text-muted">Всего заказов</small>
</div>
</div>
<div class="col-4">
<div class="p-3">
<div class="h3 mb-1 fw-bold" style="color: var(--steel-blue);">{{ orders_stats.total_sold or 0 }}</div>
<small class="text-muted">Продано единиц</small>
</div>
</div>
<div class="col-4">
<div class="p-3">
<div class="h3 mb-1 fw-bold" style="color: var(--steel-blue);">{{ (orders_stats.avg_order_size or 0)|int }}</div>
<small class="text-muted">Средний заказ</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- JavaScript для калькулятора -->
<script>
// Функция для проверки ввода только цифр
function isNumberKey(evt) {
var charCode = (evt.which) ? evt.which : evt.keyCode;
// Разрешаем цифры, backspace, delete, tab, enter, стрелки
if (charCode == 8 || charCode == 46 || charCode == 9 || charCode == 13 ||
charCode == 37 || charCode == 38 || charCode == 39 || charCode == 40) {
return true;
}
// Разрешаем только цифры (0-9)
if (charCode >= 48 && charCode <= 57) {
return true;
}
return false;
}
document.addEventListener('DOMContentLoaded', function() {
// Получаем данные товара
const price = {{ product.min_price }};
const assemblyPercent = 15;
const deliveryPercent = 10;
// Элементы для расчета стоимости
const quantityInput = document.getElementById('quantity');
const baseCostSpan = document.getElementById('baseCost');
const assemblyCostSpan = document.getElementById('assemblyCost');
const deliveryCostSpan = document.getElementById('deliveryCost');
const totalCostSpan = document.getElementById('totalCost');
// Функция расчета стоимости
function calculateTotalCost() {
const quantity = parseInt(quantityInput.value) || 1;
// Базовая стоимость
const baseCost = price * quantity;
// Стоимость сборки и доставки (фиксированные проценты)
const assemblyCost = baseCost * assemblyPercent / 100;
const deliveryCost = baseCost * deliveryPercent / 100;
// Итоговая стоимость
const totalCost = baseCost + assemblyCost + deliveryCost;
// Обновляем значения
baseCostSpan.textContent = baseCost.toLocaleString('ru-RU') + ' руб.';
assemblyCostSpan.textContent = assemblyCost.toLocaleString('ru-RU') + ' руб.';
deliveryCostSpan.textContent = deliveryCost.toLocaleString('ru-RU') + ' руб.';
totalCostSpan.textContent = totalCost.toLocaleString('ru-RU') + ' руб.';
}
// Слушатель для изменения количества
quantityInput.addEventListener('input', calculateTotalCost);
// Запрещаем вставку текста
quantityInput.addEventListener('paste', function(e) {
e.preventDefault();
return false;
});
// Первоначальный расчет
calculateTotalCost();
});
</script>
<style>
.page-title {
color: var(--steel-blue);
font-size: 2rem;
font-weight: 600;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.card {
border: 1px solid var(--light-steel-blue);
border-radius: 12px;
box-shadow: 0 4px 8px rgba(70, 130, 180, 0.1);
transition: all 0.2s ease;
}
.card:hover {
box-shadow: 0 8px 16px rgba(70, 130, 180, 0.15);
}
.table-borderless td {
padding: 8px 0;
border: none;
}
.table-bordered {
border-color: var(--light-steel-blue);
}
.table-bordered th,
.table-bordered td {
border-color: var(--light-steel-blue);
padding: 12px;
}
.table-info {
background-color: rgba(70, 130, 180, 0.1);
}
.form-label {
color: var(--steel-blue);
font-weight: 500;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control {
border: 2px solid var(--light-steel-blue);
border-radius: 8px;
padding: 10px 15px;
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
}
.form-control:focus {
border-color: var(--steel-blue);
box-shadow: 0 0 0 0.2rem rgba(70, 130, 180, 0.25);
outline: none;
}
.btn {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
border: none;
border-radius: 8px;
padding: 10px 20px;
transition: all 0.2s ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--steel-blue) 0%, #3a6d99 100%);
color: white;
}
.btn-primary:hover {
background: linear-gradient(135deg, #3a6d99 0%, var(--steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(70, 130, 180, 0.3);
}
.btn-secondary {
background: linear-gradient(135deg, var(--light-steel-blue) 0%, #9fb6d4 100%);
color: #2c3e50;
}
.btn-secondary:hover {
background: linear-gradient(135deg, #9fb6d4 0%, var(--light-steel-blue) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(176, 196, 222, 0.3);
}
.badge {
font-family: 'Franklin Gothic', 'Arial Narrow', Arial, sans-serif;
padding: 6px 12px;
}
.bg-light {
background-color: #f8f9fa !important;
}
.text-primary {
color: var(--steel-blue) !important;
}
.text-success {
color: #28a745 !important;
}
@media (max-width: 768px) {
.page-title {
font-size: 1.5rem;
}
.d-flex.justify-content-between {
flex-direction: column;
gap: 10px;
}
.d-flex.justify-content-between .btn {
width: 100%;
}
}
</style>
{% endblock %}