You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

894 lines
37 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
ITD Transport Tracking - основен екран.
Поле за четене на QR карта, регистрация по пост; генериране на QR карта; таблица с цикли.
"""
import os
import sys
import platform
import subprocess
import tkinter as tk
from tkinter import ttk, filedialog
# работима директория = тази на скрипта (при .exe = папката на exe)
# Забележка: НЕ пипаме sys.path преди локалните импорти; PyInstaller има собствен importer.
if getattr(sys, "frozen", False):
SCRIPT_DIR = os.path.dirname(sys.executable)
else:
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
from itd_db import (
get_connection,
get_last_appsettings_path,
get_last_connection_string_redacted,
load_cycles,
load_cards,
close_cycle_manually,
undo_manual_cycle_close,
get_card_by_code,
add_registration,
create_card,
update_card,
delete_card,
get_next_post_index,
has_open_cycle,
set_card_active,
utc_to_local_for_display,
)
os.chdir(SCRIPT_DIR)
POST_NAMES = {
1: "1 Портал (паркинг)",
2: "2 Вход в завода",
3: "3 Рампа начало товарене",
4: "4 Рампа край товарене",
5: "5 Напускане",
}
def _append_log(line: str):
try:
log_path = os.path.join(SCRIPT_DIR, "itd_transport.log")
with open(log_path, "a", encoding="utf-8") as f:
f.write(line.rstrip() + "\n")
except Exception:
pass
def _center_on_parent(dialog, parent):
"""Същата логика като при „Генерирай QR карта“ центриране в средата на parent."""
dialog.update_idletasks()
parent.update_idletasks()
w = dialog.winfo_width()
h = dialog.winfo_height()
if w < 50 or h < 50:
w = dialog.winfo_reqwidth()
h = dialog.winfo_reqheight()
pw = parent.winfo_width()
ph = parent.winfo_height()
px = parent.winfo_rootx()
py = parent.winfo_rooty()
if pw < 50 or ph < 50:
sw = dialog.winfo_screenwidth()
sh = dialog.winfo_screenheight()
x = max(0, (sw - w) // 2)
y = max(0, (sh - h) // 2)
else:
x = px + (pw - w) // 2
y = py + (ph - h) // 2
dialog.geometry(f"+{x}+{y}")
def _msg_centered(parent, title, message, icon_type="info"):
"""Показва модален диалог с една бутон OK центриране като при „Генерирай QR карта“."""
win = tk.Toplevel(parent)
win.withdraw()
win.transient(parent)
win.title(title)
win.resizable(False, False)
f = ttk.Frame(win, padding=20)
f.pack(fill=tk.BOTH, expand=True)
ttk.Label(f, text=message, wraplength=420).pack(pady=(0, 16))
ttk.Button(f, text="OK", command=win.destroy).pack()
_center_on_parent(win, parent)
win.deiconify()
win.grab_set()
win.focus_set()
win.wait_window()
def _ask_yesno_centered(parent, title, message):
"""Показва модален Да/Не диалог центриране като при „Генерирай QR карта“. Връща True за Да, False за Не."""
result = [False]
def on_yes():
result[0] = True
win.destroy()
def on_no():
result[0] = False
win.destroy()
win = tk.Toplevel(parent)
win.withdraw()
win.transient(parent)
win.title(title)
win.resizable(False, False)
f = ttk.Frame(win, padding=20)
f.pack(fill=tk.BOTH, expand=True)
ttk.Label(f, text=message, wraplength=420).pack(pady=(0, 16))
bf = ttk.Frame(f)
bf.pack()
ttk.Button(bf, text="Да", command=on_yes).pack(side=tk.LEFT, padx=6)
ttk.Button(bf, text="Не", command=on_no).pack(side=tk.LEFT, padx=6)
_center_on_parent(win, parent)
win.deiconify()
win.grab_set()
win.focus_set()
win.wait_window()
return result[0]
def main():
root = tk.Tk()
root.withdraw()
try:
conn = get_connection()
except Exception as e:
cfg_path = get_last_appsettings_path()
cs = get_last_connection_string_redacted()
_append_log(f"[STARTUP] DB connect failed. appsettings={cfg_path or 'N/A'}; conn={cs or 'N/A'}; error={e!s}")
root.deiconify()
root.update_idletasks()
extra = f"\n\nИзползван appsettings.json:\n{cfg_path}" if cfg_path else ""
_msg_centered(root, "Грешка", f"Не може да се свърже с базата:\n{e}{extra}")
try:
root.destroy()
except Exception:
pass
return 1
cfg_path = get_last_appsettings_path()
cs = get_last_connection_string_redacted()
_append_log(f"[STARTUP] OK. appsettings={cfg_path or 'N/A'}; conn={cs or 'N/A'}")
# Автоматично създаване на таблици при липса (трансфер на нов сървер)
try:
from db_check import run_schema, check_tables
_base = getattr(sys, "_MEIPASS", SCRIPT_DIR)
schema_path = os.path.join(_base, "schema.sql")
if os.path.isfile(schema_path):
run_schema(conn.cursor(), schema_path)
has_cards, has_cycles = check_tables(conn.cursor())
if not (has_cards and has_cycles):
root.deiconify()
root.update_idletasks()
_msg_centered(root, "Грешка", "След изпълнение на схемата липсват таблици ITD_Cards или ITD_Cycles.")
return 1
except Exception as e:
root.deiconify()
root.update_idletasks()
_msg_centered(root, "Грешка", f"Грешка при проверка/създаване на таблици:\n{e}")
return 1
root.deiconify()
root.title("ITD Transport Tracking")
root.minsize(800, 400)
root.geometry("1000x560")
root.configure(bg="white")
# Икона на приложението използваме само ITD.ico в папката на скрипта
icon_path = os.path.join(SCRIPT_DIR, "ITD.ico")
if os.path.isfile(icon_path):
try:
root.iconbitmap(icon_path)
except Exception:
pass
# PNG икона (за платформи, където .ico не се поддържа)
for icon_name in ("icon.png", "app.png"):
icon_path = os.path.join(SCRIPT_DIR, icon_name)
if os.path.isfile(icon_path):
try:
from PIL import Image
img = Image.open(icon_path)
from PIL import ImageTk
root.iconphoto(True, ImageTk.PhotoImage(img))
except Exception:
pass
break
# --- Заглавна лента: по-едър шрифт, светлосин фон, текст центриран ---
title_frame = tk.Frame(root, bg="#93C5FD", height=52)
title_frame.grid(row=0, column=0, columnspan=2, sticky="ew")
title_frame.grid_propagate(False)
root.grid_columnconfigure(0, weight=1)
title_label = tk.Label(
title_frame,
text="ITD Transport Tracking",
font=("Segoe UI", 16, "bold"),
fg="#1E3A8A",
bg="#93C5FD",
)
title_label.place(relx=0.5, rely=0.5, anchor="center")
# --- Горна лента: четене на QR карта и пост ---
scan_frame = ttk.LabelFrame(root, text="", padding=6)
scan_frame.grid(row=1, column=0, columnspan=2, sticky="ew", padx=4, pady=4)
root.grid_columnconfigure(0, weight=1)
ttk.Label(scan_frame, text="Постове / Терминали:").grid(row=0, column=0, padx=(0, 6), pady=2, sticky="w")
posts_frame = ttk.Frame(scan_frame)
posts_frame.grid(row=0, column=1, padx=0, pady=2, sticky="w")
post_colors = {
1: "#BFDBFE", # по-наситено синьо
2: "#BBF7D0", # по-наситено зелено
3: "#FDE68A", # по-наситено златисто
4: "#DDD6FE", # по-наситено лилаво
5: "#FCA5A5", # по-наситено червено
}
for i in range(1, 6):
lbl = tk.Label(
posts_frame,
text=POST_NAMES[i],
padx=10,
pady=6,
font=("Segoe UI", 9),
fg="#111827",
bg=post_colors.get(i, "#4F4F4F"),
bd=0,
relief="flat",
highlightthickness=1,
highlightbackground="#D1D5DB",
)
lbl.pack(side=tk.LEFT, padx=(0, 6))
ttk.Label(scan_frame, text="QR карта (код):").grid(row=1, column=0, padx=(0, 4), pady=(6, 4), sticky="w")
qr_row_frame = ttk.Frame(scan_frame)
qr_row_frame.grid(row=1, column=1, padx=0, pady=(6, 4), sticky="ew")
scan_frame.columnconfigure(1, weight=1)
qr_entry = ttk.Entry(qr_row_frame, width=40, font=("Segoe UI", 10))
qr_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8), ipady=2)
def on_register():
code = qr_entry.get().strip()
if not code:
root.update_idletasks()
_msg_centered(root, "Информация", "Въведете или сканирайте кода на картата.")
qr_entry.focus_set()
return
card = get_card_by_code(conn, code)
if not card:
root.update_idletasks()
_msg_centered(root, "Внимание", f"Карта с код „{code}” не е намерена или е неактивна.")
qr_entry.select_range(0, tk.END)
qr_entry.focus_set()
return
try:
post_idx = get_next_post_index(conn, card["id"])
if post_idx == 1 and has_open_cycle(conn, card["id"]):
root.update_idletasks()
_msg_centered(
root,
"Внимание",
"Вече сте влезли. Моля, излезте преди нова регистрация.",
)
qr_entry.select_range(0, tk.END)
qr_entry.focus_set()
return
add_registration(conn, card["id"], post_idx)
qr_entry.delete(0, tk.END)
qr_entry.focus_set()
refresh()
root.update_idletasks()
_msg_centered(root, "Готово", f"Регистрирано: {card['display_text']} на пост {post_idx}.")
except Exception as e:
root.update_idletasks()
_msg_centered(root, "Грешка", f"Неуспешна регистрация:\n{e}")
ttk.Button(qr_row_frame, text="Регистрирай", command=on_register).pack(side=tk.LEFT)
qr_entry.bind("<Return>", lambda e: on_register())
def open_qr_dialog():
win = tk.Toplevel(root)
win.title("Генериране на QR карта")
win.transient(root)
win.grab_set()
f = ttk.Frame(win, padding=12)
f.pack(fill=tk.BOTH, expand=True)
ttk.Label(f, text="Код (ID):").grid(row=0, column=0, sticky="w", pady=2)
code_label = ttk.Label(f, text="", foreground="gray")
code_label.grid(row=0, column=1, padx=8, pady=2, sticky="w")
ttk.Label(f, text="(задава се автоматично след запис в базата)").grid(
row=0, column=2, sticky="w", pady=2
)
ttk.Label(f, text="Текст на картата (име + № кола):").grid(row=1, column=0, sticky="w", pady=2)
text_ent = ttk.Entry(f, width=35)
text_ent.grid(row=1, column=1, padx=8, pady=2, columnspan=2, sticky="ew")
f.columnconfigure(1, weight=1)
save_to_db_var = tk.BooleanVar(value=True)
ttk.Checkbutton(f, text="Запиши карта в базата", variable=save_to_db_var).grid(
row=2, column=0, columnspan=2, sticky="w", pady=4
)
qr_label = ttk.Label(f, text="")
qr_label.grid(row=3, column=0, columnspan=2, pady=8)
def do_generate():
display_text = text_ent.get().strip()
if not display_text:
win.update_idletasks()
_msg_centered(win, "Внимание", "Въведете текст на картата.")
text_ent.focus_set()
return
if not save_to_db_var.get():
win.update_idletasks()
_msg_centered(
win,
"Внимание",
"За да генерирате QR карта с код, отметнете „Запиши карта в базата“.",
)
return
try:
new_id = create_card(conn, display_text)
code = str(new_id)
code_label.config(text=code, foreground="#111827")
import qrcode
from PIL import ImageTk
qr = qrcode.QRCode(version=1, box_size=6, border=2)
qr.add_data(code)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
img = img.convert("RGB").resize((220, 220))
ph = ImageTk.PhotoImage(img)
qr_label.configure(image=ph, text="")
qr_label.image = ph
win.update_idletasks()
_msg_centered(win, "Готово", f"Картата е записана с код {code}. Отпечатайте QR кода по-долу.")
except Exception as ex:
win.update_idletasks()
_msg_centered(win, "Грешка", str(ex))
ttk.Button(f, text="Генерирай QR", command=do_generate).grid(row=4, column=0, columnspan=2, pady=6)
# центриране на прозореца в средата на главния прозорец
win.update_idletasks()
root.update_idletasks()
w = win.winfo_width()
h = win.winfo_height()
rw = root.winfo_width()
rh = root.winfo_height()
rx = root.winfo_rootx()
ry = root.winfo_rooty()
x = rx + (rw - w) // 2
y = ry + (rh - h) // 2
win.geometry(f"+{x}+{y}")
text_ent.focus_set()
# Филтър по номер на карта
filter_frame = ttk.Frame(root)
filter_frame.grid(row=2, column=0, columnspan=2, sticky="ew", padx=4, pady=(0, 4))
root.grid_columnconfigure(0, weight=1)
ttk.Label(filter_frame, text="Филтър по карта (номер):").pack(side=tk.LEFT, padx=(0, 6))
filter_entry = ttk.Entry(filter_frame, width=14, font=("Segoe UI", 10))
filter_entry.pack(side=tk.LEFT, padx=(0, 6))
card_filter = [None] # mutable, за достъп от вложени функции
def apply_filter():
card_filter[0] = (filter_entry.get() or "").strip() or None
refresh()
def clear_filter():
filter_entry.delete(0, tk.END)
card_filter[0] = None
refresh()
ttk.Button(filter_frame, text="Филтрирай", command=apply_filter).pack(side=tk.LEFT, padx=2)
ttk.Button(filter_frame, text="Всички", command=clear_filter).pack(side=tk.LEFT, padx=2)
# платно за визуализация на цикли с цветни постове
row_height = 40
canvas = tk.Canvas(root, background="white")
scroll_y = ttk.Scrollbar(root, orient=tk.VERTICAL, command=canvas.yview)
canvas.configure(yscrollcommand=scroll_y.set)
# данни за редовете (card_id, cycle_started_at и др.)
cycles_data = []
selected_row_index = None
post_colors = {
1: "#BFDBFE", # по-наситено синьо
2: "#BBF7D0", # по-наситено зелено
3: "#FDE68A", # по-наситено златисто
4: "#DDD6FE", # по-наситено лилаво
5: "#FCA5A5", # по-наситено червено
}
def draw_rows():
canvas.delete("all")
width = canvas.winfo_width() or 1000
card_width = 200
post_width = 108
gap = 4
# Служебен изход / изход веднага след пост 5
after_posts_x = card_width + 5 * (post_width + gap)
exit_x = after_posts_x + 8
for idx, r in enumerate(cycles_data):
y0 = idx * row_height
y1 = y0 + row_height
# фон на реда (за селекция)
bg_color = "#E5E7EB" if idx == selected_row_index else "#FFFFFF"
canvas.create_rectangle(0, y0, width, y1, fill=bg_color, outline="#E5E7EB")
# текст на картата: "код" - "име"
card_label = f'{r.get("code", "")} - {r["display_text"]}' if r.get("code") else r["display_text"]
canvas.create_text(
8,
(y0 + y1) / 2,
text=card_label,
anchor="w",
font=("Segoe UI", 9),
fill="#111827",
)
# 5 цветни правоъгълника за постовете
for p in range(1, 6):
x0 = card_width + (p - 1) * (post_width + gap)
x1 = x0 + post_width
txt = r.get(f"post{p}", "")
canvas.create_rectangle(
x0,
y0 + 4,
x1,
y1 - 4,
fill=post_colors.get(p, "#9CA3AF"),
outline="#E5E7EB",
)
canvas.create_text(
(x0 + x1) / 2,
(y0 + y1) / 2,
text=txt,
anchor="center",
font=("Segoe UI", 8),
fill="#111827",
)
# изход / служебен изход (веднага след пост 5)
exit_text = r.get("exit_datetime", "")
exit_fill = "#B91C1C" if r.get("service_exit") else "#111827"
canvas.create_text(
exit_x,
(y0 + y1) / 2,
text=exit_text,
anchor="w",
font=("Segoe UI", 9),
fill=exit_fill,
)
canvas.configure(scrollregion=canvas.bbox("all"))
def refresh(event=None):
nonlocal cycles_data, selected_row_index
cycles_data.clear()
selected_row_index = None
try:
rows = load_cycles(conn)
except Exception as e:
root.update_idletasks()
_msg_centered(root, "Грешка", f"Грешка при зареждане:\n{e}")
return
cf = card_filter[0]
if cf:
cf_str = str(cf).strip()
rows = [r for r in rows if str(r.get("code", "")).strip() == cf_str]
for r in rows:
cycles_data.append(r)
status_label.config(text=f"Заредени {len(cycles_data)} цикъла" + (f" (филтър: {cf})" if cf else ""))
draw_rows()
def on_canvas_click(event):
nonlocal selected_row_index
y = canvas.canvasy(event.y)
idx = int(y // row_height)
if 0 <= idx < len(cycles_data):
selected_row_index = idx
draw_rows()
canvas.bind("<Button-1>", on_canvas_click)
canvas.bind("<Configure>", refresh)
def open_cards_dialog():
"""Модул 'Карти' списък на всички карти с (де)активиране."""
win = tk.Toplevel(root)
win.title("Карти")
win.transient(root)
win.grab_set()
win.configure(bg="#FFFDE7")
root.update_idletasks()
rw = root.winfo_width()
rh = root.winfo_height()
w_win = max(520, min(rw - 80, 920))
h_win = max(400, min(rh - 80, 620))
win.geometry(f"{w_win}x{h_win}")
frame = tk.Frame(win, bg="#FFFDE7", padx=12, pady=12)
frame.pack(fill=tk.BOTH, expand=True)
# Таблица като решетка: сиво очертаване (не черно)
cell_bg = "#FFFDE7"
header_bg = "#F5F0D7"
selected_bg = "#E8E0C8"
table_border_gray = "#9CA3AF"
col_widths = (18, 32, 10, 18, 18) # ширина в знаци
canvas_wrap = tk.Canvas(frame, bg=cell_bg, highlightthickness=0)
scroll_cards = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas_wrap.yview)
border_frame = tk.Frame(canvas_wrap, bg=table_border_gray, padx=1, pady=1)
table_frame = tk.Frame(border_frame, bg=cell_bg)
table_frame.pack(fill=tk.BOTH, expand=True)
table_frame.bind(
"<Configure>",
lambda e: canvas_wrap.configure(scrollregion=canvas_wrap.bbox("all")),
)
canvas_wrap.create_window((0, 0), window=border_frame, anchor="nw")
canvas_wrap.configure(yscrollcommand=scroll_cards.set)
def _on_canvas_configure(event):
for item in canvas_wrap.find_all():
canvas_wrap.itemconfig(item, width=event.width)
canvas_wrap.bind("<Configure>", _on_canvas_configure)
cards_data = []
selected_row_index = [None] # list to allow assign in nested func
def fmt_dt_short(dt):
if not dt:
return ""
try:
if hasattr(dt, "strftime"):
return dt.strftime("%d.%m.%Y %H:%M")
s = str(dt)
return s.split(".")[0].replace("T", " ")
except Exception:
return str(dt)
def clear_table():
for w in table_frame.grid_slaves():
w.destroy()
def on_select_row(idx):
selected_row_index[0] = idx
rebuild_table()
def rebuild_table():
clear_table()
# Ред 0: заглавки всяка клетка в сива рамка (не черна)
headers = ("Код", "Текст (име + № кола)", "Активна", "Създадена на", "Деактивирана на")
for c, (text, wch) in enumerate(zip(headers, col_widths)):
cell_f = tk.Frame(table_frame, bg=table_border_gray, padx=1, pady=1)
cell_f.grid(row=0, column=c, sticky="nsew", padx=0, pady=0)
lbl = tk.Label(
cell_f,
text=text,
width=wch,
font=("Segoe UI", 9, "bold"),
bg=header_bg,
anchor="center",
)
lbl.pack(fill=tk.BOTH, expand=True)
table_frame.columnconfigure(1, weight=1)
for r_idx, r in enumerate(cards_data):
deact_date = ""
if not r["is_active"] and r.get("updated_at"):
deact_date = fmt_dt_short(utc_to_local_for_display(r["updated_at"]))
row_bg = selected_bg if selected_row_index[0] == r_idx else cell_bg
vals = (
r["code"],
r["display_text"],
"Да" if r["is_active"] else "Не",
fmt_dt_short(utc_to_local_for_display(r["created_at"])),
deact_date,
)
for c, (val, wch) in enumerate(zip(vals, col_widths)):
cell_f = tk.Frame(table_frame, bg=table_border_gray, padx=1, pady=1)
cell_f.grid(row=r_idx + 1, column=c, sticky="nsew", padx=0, pady=0)
lbl = tk.Label(
cell_f,
text=val or "",
width=wch,
font=("Segoe UI", 9),
bg=row_bg,
anchor="w" if c != 2 else "center",
)
lbl.pack(fill=tk.BOTH, expand=True)
cell_f.bind("<Button-1>", lambda e, i=r_idx: on_select_row(i))
lbl.bind("<Button-1>", lambda e, i=r_idx: on_select_row(i))
def reload_cards():
cards_data.clear()
selected_row_index[0] = None
try:
rows = load_cards(conn)
except Exception as ex:
_msg_centered(win, "Грешка", f"Грешка при зареждане на карти:\n{ex}")
return
for r in rows:
cards_data.append(r)
rebuild_table()
def get_selected_card():
idx = selected_row_index[0]
if idx is None or idx < 0 or idx >= len(cards_data):
_msg_centered(win, "Информация", "Изберете карта от списъка (клик върху ред).")
return None
return cards_data[idx]
def on_deactivate_card():
card = get_selected_card()
if not card:
return
if not card["is_active"]:
_msg_centered(win, "Информация", "Картата вече е деактивирана.")
return
if not _ask_yesno_centered(
win,
"Потвърждение",
"Деактивирате ли тази карта?\n\nСлед деактивация тя няма да може да се използва за регистрация.",
):
return
try:
set_card_active(conn, card["id"], False)
_msg_centered(win, "Готово", "Картата е деактивирана.")
reload_cards()
refresh()
except Exception as ex:
_msg_centered(win, "Грешка", f"Грешка при деактивиране:\n{ex}")
def on_activate_card():
card = get_selected_card()
if not card:
return
if card["is_active"]:
_msg_centered(win, "Информация", "Картата вече е активна.")
return
if not _ask_yesno_centered(
win,
"Потвърждение",
"Активирате ли тази карта отново?",
):
return
try:
set_card_active(conn, card["id"], True)
_msg_centered(win, "Готово", "Картата е активирана отново.")
reload_cards()
refresh()
except Exception as ex:
_msg_centered(win, "Грешка", f"Грешка при активиране:\n{ex}")
def create_card_png():
card = get_selected_card()
if not card:
return
path = filedialog.asksaveasfilename(
title="Запази карта като PNG",
defaultextension=".png",
filetypes=[("PNG изображение", "*.png"), ("Всички файлове", "*.*")],
initialfile=f"karta_{card['code']}.png",
)
if not path:
return
try:
import qrcode
from PIL import Image, ImageDraw, ImageFont
code = str(card["code"])
text = str(card["display_text"] or "")
# Размер като визитка (прибл. 90×54 mm при 300 dpi), QR колкото може по-голям
w, h = 1063, 638 # ~90×54 mm @ 300 dpi
img = Image.new("RGB", (w, h), "white")
draw = ImageDraw.Draw(img)
draw.rectangle([(0, 0), (w - 1, h - 1)], outline="black", width=2)
margin = 16
# QR заема максимално височината на картата
qr_size = h - 2 * margin
qr = qrcode.QRCode(version=1, box_size=10, border=2)
qr.add_data(code)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
qr_img = qr_img.resize((qr_size, qr_size))
qr_x = w - margin - qr_size
qr_y = margin
img.paste(qr_img, (qr_x, qr_y))
# Текст вляво компактен
try:
font_code = ImageFont.truetype("arial.ttf", 26)
font_label = ImageFont.truetype("arial.ttf", 14)
font_text = ImageFont.truetype("arial.ttf", 20)
except Exception:
font_code = font_label = font_text = ImageFont.load_default()
tx, ty = margin, margin
draw.text((tx, ty), f"Код: {code}", fill="black", font=font_code)
ty += 38
draw.text((tx, ty), "Име / № кола:", fill="gray", font=font_label)
ty += 22
max_chars = max(12, (qr_x - tx - 12) // 12)
for line in (text[i : i + max_chars] for i in range(0, len(text), max_chars)):
draw.text((tx, ty), line, fill="black", font=font_text)
ty += 26
img.save(path)
win.update_idletasks()
_msg_centered(win, "Готово", f"Файлът е запазен:\n{path}")
# Отваряне на файла с програмата по подразбиране за преглед
try:
path_abs = os.path.abspath(path)
if platform.system() == "Windows":
os.startfile(path_abs)
elif platform.system() == "Darwin":
subprocess.run(["open", path_abs], check=False)
else:
subprocess.run(["xdg-open", path_abs], check=False)
except Exception:
pass
except Exception as ex:
win.update_idletasks()
_msg_centered(win, "Грешка", f"Неуспешно създаване на PNG:\n{ex}")
def on_edit_card():
card = get_selected_card()
if not card:
return
edit_win = tk.Toplevel(win)
edit_win.title("Коригиране на карта")
edit_win.transient(win)
edit_win.grab_set()
f_edit = ttk.Frame(edit_win, padding=12)
f_edit.pack(fill=tk.BOTH, expand=True)
ttk.Label(f_edit, text="Код (ID):").grid(row=0, column=0, sticky="w", pady=2)
ttk.Label(f_edit, text=card["code"]).grid(row=0, column=1, padx=8, pady=2, sticky="w")
ttk.Label(f_edit, text="Текст (име + № кола):").grid(row=1, column=0, sticky="w", pady=2)
text_ent = ttk.Entry(f_edit, width=40)
text_ent.insert(0, card["display_text"] or "")
text_ent.grid(row=1, column=1, padx=8, pady=2, sticky="ew")
f_edit.columnconfigure(1, weight=1)
def do_save():
new_text = text_ent.get().strip()
if not new_text:
_msg_centered(edit_win, "Внимание", "Въведете текст на картата.")
return
try:
update_card(conn, card["id"], new_text)
_msg_centered(edit_win, "Готово", "Картата е коригирана.")
edit_win.destroy()
reload_cards()
refresh()
except Exception as ex:
_msg_centered(edit_win, "Грешка", str(ex))
btn_row = ttk.Frame(f_edit)
btn_row.grid(row=2, column=0, columnspan=2, pady=(12, 0))
ttk.Button(btn_row, text="Запази", command=do_save).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_row, text="Отказ", command=edit_win.destroy).pack(side=tk.LEFT, padx=4)
_center_on_parent(edit_win, win)
text_ent.focus_set()
def on_delete_card():
card = get_selected_card()
if not card:
return
if not _ask_yesno_centered(
win,
"Потвърждение",
f"Изтриване на карта?\n\nКод: {card['code']}\nТекст: {card['display_text']}\n\nЩе бъдат изтрити и всички регистрации по тази карта.",
):
return
try:
delete_card(conn, card["id"])
_msg_centered(win, "Готово", "Картата е изтрита.")
reload_cards()
refresh()
except Exception as ex:
_msg_centered(win, "Грешка", f"Грешка при изтриване:\n{ex}")
canvas_wrap.grid(row=0, column=0, sticky="nsew")
scroll_cards.grid(row=0, column=1, sticky="ns")
frame.rowconfigure(0, weight=1)
frame.columnconfigure(0, weight=1)
btn_bar = ttk.Frame(frame)
btn_bar.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(8, 0))
ttk.Button(btn_bar, text="Обнови", command=reload_cards).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Коригирай", command=on_edit_card).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Изтрий", command=on_delete_card).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Деактивирай", command=on_deactivate_card).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Активирай", command=on_activate_card).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Създай PNG", command=create_card_png).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Генерирай QR карта", command=open_qr_dialog).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_bar, text="Затвори", command=win.destroy).pack(side=tk.RIGHT, padx=4)
reload_cards()
_center_on_parent(win, root)
def on_close_cycle():
nonlocal selected_row_index
if selected_row_index is None or selected_row_index >= len(cycles_data):
root.update_idletasks()
_msg_centered(root, "Информация", "Изберете ред от списъка (клик с мишката).")
return
row = cycles_data[selected_row_index]
root.update_idletasks()
if not _ask_yesno_centered(root, "Потвърждение", "Служебно затваряне на цикъла за тази карта?"):
return
try:
updated = close_cycle_manually(conn, row["cycle_id"])
root.update_idletasks()
if updated:
_msg_centered(root, "Готово", "Цикълът е затворен. Записан е Служебен изход.")
else:
_msg_centered(root, "Информация", "Цикълът вече е затворен служебно.")
refresh()
except Exception as e:
root.update_idletasks()
_msg_centered(root, "Грешка", f"Неуспешно затваряне:\n{e}")
def on_undo_service_exit():
nonlocal selected_row_index
if selected_row_index is None or selected_row_index >= len(cycles_data):
root.update_idletasks()
_msg_centered(root, "Информация", "Изберете ред от списъка (клик с мишката).")
return
row = cycles_data[selected_row_index]
if not row.get("service_exit"):
root.update_idletasks()
_msg_centered(root, "Информация", "Изберете цикъл със служебен изход за отмяна.")
return
root.update_idletasks()
if not _ask_yesno_centered(root, "Потвърждение", "Да се премахне служебният изход за този цикъл?"):
return
try:
removed = undo_manual_cycle_close(conn, row["cycle_id"])
root.update_idletasks()
if removed:
_msg_centered(root, "Готово", "Служебният изход е отменен.")
else:
_msg_centered(root, "Информация", "Записът за служебен изход вече липсва.")
refresh()
except Exception as e:
root.update_idletasks()
_msg_centered(root, "Грешка", f"Неуспешна отмяна:\n{e}")
# бутони и статус
btn_frame = ttk.Frame(root)
ttk.Button(btn_frame, text="Обнови", command=refresh).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Затвори цикъла", command=on_close_cycle).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Отмени служебен изход", command=on_undo_service_exit).pack(side=tk.LEFT, padx=4)
ttk.Button(btn_frame, text="Карти", command=open_cards_dialog).pack(side=tk.LEFT, padx=12)
status_label = ttk.Label(btn_frame, text="")
status_label.pack(side=tk.LEFT, padx=12)
# подредба
canvas.grid(row=3, column=0, sticky="nsew")
scroll_y.grid(row=3, column=1, sticky="ns")
btn_frame.grid(row=4, column=0, columnspan=2, sticky="ew", pady=8, padx=4)
root.grid_rowconfigure(3, weight=1)
root.grid_columnconfigure(0, weight=1)
refresh()
root.mainloop()
try:
conn.close()
except Exception:
pass
return 0
if __name__ == "__main__":
sys.exit(main())