wip: local portal changes (backup before disk cleanup)
This commit is contained in:
9
offer-to-order/.dockerignore
Normal file
9
offer-to-order/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.env
|
||||
.env.*
|
||||
samples/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
*.xlsx
|
||||
*.pdf
|
||||
4
offer-to-order/.env.example
Normal file
4
offer-to-order/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
|
||||
LITELLM_API_KEY=sk-...
|
||||
LLM_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
PROCESS_API_KEY=oto_change-me
|
||||
6
offer-to-order/.gitignore
vendored
Normal file
6
offer-to-order/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.env.local
|
||||
__pycache__/
|
||||
*.pyc
|
||||
samples/
|
||||
*.xlsx
|
||||
19
offer-to-order/Dockerfile
Normal file
19
offer-to-order/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# DejaVu fonts so reportlab renders Czech diacritics in the order-form PDF.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /tmp/offer-to-order
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
20
offer-to-order/docker-compose.prod.yml
Normal file
20
offer-to-order/docker-compose.prod.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Standalone production deployment (fresh host, no portal infra).
|
||||
# The host is VPN-only, so the app port is the access boundary — there is
|
||||
# no separate login. Bind only to the interface reachable over your VPN.
|
||||
#
|
||||
# docker compose -f docker-compose.prod.yml up -d --build
|
||||
#
|
||||
# Configure secrets/endpoint in .env (see .env.prod.example).
|
||||
services:
|
||||
offer-to-order:
|
||||
build: .
|
||||
container_name: offer-to-order
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Host port 8080 → container 8000. Reachable over the VPN at
|
||||
# http://<prod-host>:8080/. Change the left side if 8080 is taken.
|
||||
- "8080:8000"
|
||||
environment:
|
||||
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-https://litellm.klas.chat/v1}
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY:?set LITELLM_API_KEY in .env}
|
||||
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
|
||||
25
offer-to-order/docker-compose.yml
Normal file
25
offer-to-order/docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
offer-to-order:
|
||||
build: .
|
||||
container_name: offer-to-order
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3037:8000"
|
||||
environment:
|
||||
LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1}
|
||||
LITELLM_API_KEY: ${LITELLM_API_KEY:-}
|
||||
LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514}
|
||||
PROCESS_API_KEY: ${PROCESS_API_KEY:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- localai
|
||||
volumes:
|
||||
- offer-to-order-data:/tmp/offer-to-order
|
||||
|
||||
volumes:
|
||||
offer-to-order-data:
|
||||
|
||||
networks:
|
||||
localai:
|
||||
external: true
|
||||
83
offer-to-order/excel_export.py
Normal file
83
offer-to-order/excel_export.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""Render extracted supplier-offer data into the Excel form the CFO defined:
|
||||
|
||||
Supplier header (Jméno, DIČ/IČO, kontaktní email, adresa)
|
||||
then a table:
|
||||
Název zboží | ID zboží dodavatele | Množství | Jednotka | Jednotková cena | Cena celkem
|
||||
"""
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
HEADER_FILL = PatternFill("solid", fgColor="2563EB")
|
||||
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
|
||||
LABEL_FONT = Font(bold=True, size=10)
|
||||
THIN = Side(style="thin", color="D0D5DC")
|
||||
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||
|
||||
COLUMNS = [
|
||||
("Název zboží", "name", 46),
|
||||
("ID zboží dodavatele", "supplier_id", 20),
|
||||
("Množství", "quantity", 12),
|
||||
("Jednotka", "unit", 12),
|
||||
("Jednotková cena", "unit_price", 16),
|
||||
("Cena celkem", "total_price", 16),
|
||||
]
|
||||
|
||||
|
||||
def write_offer_xlsx(data: dict, out_path: str) -> None:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Nabídka"
|
||||
|
||||
sup = data.get("supplier") or {}
|
||||
ico_dic = " / ".join(x for x in (sup.get("ico"), sup.get("dic")) if x) or ""
|
||||
|
||||
# ── Supplier header block ──
|
||||
ws.cell(row=1, column=1, value="Dodavatel").font = Font(bold=True, size=13)
|
||||
header_pairs = [
|
||||
("Jméno", sup.get("name")),
|
||||
("DIČ / IČO", ico_dic),
|
||||
("Kontaktní email", sup.get("email")),
|
||||
("Adresa", sup.get("address")),
|
||||
]
|
||||
row = 2
|
||||
for label, val in header_pairs:
|
||||
ws.cell(row=row, column=1, value=label).font = LABEL_FONT
|
||||
ws.cell(row=row, column=2, value=val if val is not None else "")
|
||||
row += 1
|
||||
|
||||
row += 1 # spacer
|
||||
|
||||
# ── Line-items table ──
|
||||
for c, (title, _key, _w) in enumerate(COLUMNS, 1):
|
||||
cell = ws.cell(row=row, column=c, value=title)
|
||||
cell.font = HEADER_FONT
|
||||
cell.fill = HEADER_FILL
|
||||
cell.alignment = Alignment(horizontal="center", wrap_text=True)
|
||||
cell.border = BORDER
|
||||
row += 1
|
||||
|
||||
for item in data.get("line_items") or []:
|
||||
for c, (_title, key, _w) in enumerate(COLUMNS, 1):
|
||||
v = item.get(key)
|
||||
cell = ws.cell(row=row, column=c, value=v if v is not None else "")
|
||||
cell.border = BORDER
|
||||
if key in ("unit_price", "total_price") and isinstance(v, (int, float)):
|
||||
cell.number_format = "#,##0.00"
|
||||
elif key == "quantity" and isinstance(v, (int, float)):
|
||||
# Whole numbers get a pure integer format with NO decimal
|
||||
# section — some viewers render the decimal separator even
|
||||
# when "0.###" has no fractional digits, leaving a stray
|
||||
# comma. Only show decimals when the value actually has them.
|
||||
cell.number_format = "0" if float(v).is_integer() else "0.###"
|
||||
cell.alignment = Alignment(
|
||||
horizontal="left" if key in ("name", "supplier_id", "unit")
|
||||
else "right",
|
||||
vertical="top", wrap_text=(key == "name"))
|
||||
row += 1
|
||||
|
||||
for c, (_title, _key, w) in enumerate(COLUMNS, 1):
|
||||
ws.column_dimensions[get_column_letter(c)].width = w
|
||||
|
||||
ws.freeze_panes = ws.cell(row=7, column=1) # keep header + table head visible
|
||||
wb.save(out_path)
|
||||
284
offer-to-order/extractor.py
Normal file
284
offer-to-order/extractor.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Supplier-offer extraction via Claude.
|
||||
|
||||
Inputs are supplier price offers (Czech "nabídka") in wildly varied layouts —
|
||||
PDF (text or scanned) and XLSX. We extract the SUPPLIER's identity plus the
|
||||
line items into a fixed schema the CFO defined. Colsys s.r.o. (IČO 14799634)
|
||||
is always the BUYER, never the supplier.
|
||||
"""
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import openpyxl
|
||||
from PIL import Image
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_client: AsyncOpenAI | None = None
|
||||
MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514")
|
||||
MAX_PAGES = 8
|
||||
MAX_IMG_LONG_EDGE = 2200
|
||||
MAX_XLSX_ROWS = 400 # guard against huge sheets blowing the prompt
|
||||
|
||||
|
||||
def _get_client() -> AsyncOpenAI:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = AsyncOpenAI(
|
||||
base_url=os.getenv("LITELLM_BASE_URL",
|
||||
"http://host.docker.internal:4000/v1"),
|
||||
api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """Jste přesný extraktor dat z českých dodavatelských nabídek (cenových nabídek).
|
||||
Z poskytnutého dokumentu vyextrahujete údaje o DODAVATELI a seznam položek do pevného JSON tvaru.
|
||||
|
||||
DŮLEŽITÉ — kdo je dodavatel:
|
||||
- DODAVATEL = firma, která nabídku VYSTAVILA a nabízí zboží (prodávající).
|
||||
- Firma „Colsys s.r.o." (IČO 14799634, DIČ CZ14799634) je VŽDY ODBĚRATEL (kupující), NIKDY dodavatel. Pokud ji uvidíte, NEzapisujte ji jako dodavatele.
|
||||
- Dodavatel je tedy ta druhá strana (např. Rasel, ELKOV elektro, ABBAS apod.).
|
||||
|
||||
Vraťte POUZE platný JSON, žádné markdown obaly, žádný komentář mimo JSON:
|
||||
|
||||
{
|
||||
"is_offer": true,
|
||||
"offer_number": "string|null",
|
||||
"supplier": {
|
||||
"name": "string|null",
|
||||
"ico": "string|null",
|
||||
"dic": "string|null",
|
||||
"email": "string|null",
|
||||
"address": "string|null"
|
||||
},
|
||||
"currency": "CZK",
|
||||
"line_items": [
|
||||
{
|
||||
"name": "string",
|
||||
"supplier_id": "string|null",
|
||||
"quantity": "number|null",
|
||||
"unit": "string|null",
|
||||
"unit_price": "number|null",
|
||||
"total_price": "number|null"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Pravidla:
|
||||
- is_offer = false, pokud dokument NENÍ dodavatelská nabídka/cenová nabídka zboží (např. mzdová tabulka, výkaz směn, obecný dokument). V takovém případě nech supplier prázdné a line_items jako [].
|
||||
- "offer_number" = číslo nabídky dodavatele (např. „Nabídka č. NP250703175", „25NA00023"). Pokud není, null.
|
||||
- "name" = název zboží přesně jak je uvedeno.
|
||||
- "supplier_id" = kód/ID zboží u dodavatele (sloupce „Kód", „Obj. kód", „Kód zboží", „Katalogové číslo"). Pokud není, null.
|
||||
- "quantity" = množství (číslo).
|
||||
- "unit" = měrná jednotka (m, ks, kg, bal, ...). Zachovej přesně.
|
||||
- "unit_price" = jednotková cena BEZ DPH za jednotku (sloupce „J.cena", „Cena/MJ", „Cena/mj", „Jednotková cena"). Pokud je uvedena jen cena s DPH, použij ji a poznamenej to není potřeba — prostě vrať dostupnou jednotkovou cenu.
|
||||
- "total_price" = cena za položku celkem BEZ DPH (sloupce „Celkem bez DPH", „Cena celkem", „Celkem"). Pokud není uvedena, dopočítej quantity × unit_price.
|
||||
- Čísla zapisuj s tečkou jako desetinným oddělovačem, BEZ mezer a BEZ symbolu měny (např. 1289.40, ne „1 289,40 Kč").
|
||||
- Zachovej PŘESNÉ pořadí položek tak, jak jsou v dokumentu.
|
||||
- Vynech řádky, které nejsou skutečné položky zboží (mezisoučty, „Součet položek", „CELKEM K ÚHRADĚ", rekapitulace DPH, prázdné řádky).
|
||||
- Pokud údaj chybí, použij null. NEVYMÝŠLEJ si hodnoty."""
|
||||
|
||||
|
||||
async def extract_offer(path: Path) -> dict:
|
||||
"""Extract structured supplier-offer data. Routes PDF→vision, XLSX→text."""
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".pdf":
|
||||
content = _pdf_content(path)
|
||||
elif suffix in (".xlsx", ".xlsm", ".xls"):
|
||||
content = _xlsx_content(path)
|
||||
else:
|
||||
raise RuntimeError(f"Nepodporovaný formát: {suffix}")
|
||||
|
||||
if not content:
|
||||
raise RuntimeError("Soubor neobsahuje žádný čitelný obsah")
|
||||
|
||||
try:
|
||||
resp = await _get_client().chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
temperature=0.0,
|
||||
max_tokens=8000,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("LLM call failed")
|
||||
raise RuntimeError(f"AI extrakce selhala: {exc}")
|
||||
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("JSON parse failed: %s\n%s", exc, raw[:500])
|
||||
raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {exc}")
|
||||
|
||||
return _normalize(data)
|
||||
|
||||
|
||||
ORDER_EMAIL_PROMPT = """Jste extraktor dat z českého e-mailu, kterým zaměstnanec Colsys žádá o vystavení objednávky.
|
||||
Z textu e-mailu vytáhněte údaje objednávky do pevného JSON tvaru. Text může pocházet z HTML e-mailu (různé zalomení, mezery).
|
||||
|
||||
Vraťte POUZE platný JSON, žádné markdown, žádný komentář:
|
||||
|
||||
{
|
||||
"dodavatel": {
|
||||
"name": "string|null",
|
||||
"ico": "string|null",
|
||||
"dic": "string|null",
|
||||
"email": "string|null",
|
||||
"kontakt": "string|null"
|
||||
},
|
||||
"nazev_zakazky": "string|null",
|
||||
"kod_zakazky": "string|null",
|
||||
"text": "string|null",
|
||||
"cena_celkem_text": "string|null",
|
||||
"termin_dodani": "string|null",
|
||||
"kontaktni_osoba": "string|null",
|
||||
"misto_dodani": "string|null",
|
||||
"splatnost": "string|null",
|
||||
"zaruka": "string|null",
|
||||
"prilohy": "string|null",
|
||||
"zpusob_odeslani": "string|null"
|
||||
}
|
||||
|
||||
Pravidla:
|
||||
- Hodnoty hledejte u odpovídajících štítků: „Dodavatel", „IČ"/„IČO", „DIČ", „KONTAKTNÍ OSOBA DODAVATELE" → dodavatel.kontakt (jméno + tel + email; email i do dodavatel.email), „NÁZEV ZAKÁZKY", „KÓD ZAKÁZKY", „TEXT", „CENA CELKEM" → cena_celkem_text (přepište přesně, např. „-" pokud není), „TERMÍN DODÁNÍ", „KONTAKTNÍ OSOBA V MÍSTĚ DODÁNÍ", „MÍSTO DODÁNÍ", „SPLATNOST", „ZÁRUKA", „PŘÍLOHY", „ZPŮSOB ODESLÁNÍ OBJEDNÁVKY".
|
||||
- TERMÍN DODÁNÍ zapište jako DD.MM.RRRR pokud je to datum.
|
||||
- Ignorujte patičkový právní text o VNP/VSP a větu „tento text výše je obsažen v zápatí…".
|
||||
- Pokud údaj chybí, použijte null. NEVYMÝŠLEJTE si."""
|
||||
|
||||
|
||||
async def parse_order_email(body_text: str) -> dict:
|
||||
"""Extract order-form fields from the structured request e-mail body."""
|
||||
body_text = (body_text or "").strip()
|
||||
if not body_text:
|
||||
return {}
|
||||
try:
|
||||
resp = await _get_client().chat.completions.create(
|
||||
model=MODEL,
|
||||
messages=[
|
||||
{"role": "system", "content": ORDER_EMAIL_PROMPT},
|
||||
{"role": "user", "content": body_text[:20000]},
|
||||
],
|
||||
temperature=0.0,
|
||||
max_tokens=2000,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("order-email LLM call failed")
|
||||
raise RuntimeError(f"AI zpracování e-mailu selhalo: {exc}")
|
||||
raw = (resp.choices[0].message.content or "").strip()
|
||||
raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
logger.error("order-email JSON parse failed: %s\n%s", exc, raw[:400])
|
||||
return {}
|
||||
|
||||
|
||||
def _pdf_content(path: Path) -> list[dict]:
|
||||
"""PDF → page images for the vision model, plus the extracted text layer
|
||||
(helps with exact codes/prices that can be hard to OCR from the raster)."""
|
||||
doc = fitz.open(str(path))
|
||||
content: list[dict] = [{
|
||||
"type": "text",
|
||||
"text": "Níže je dodavatelská nabídka jako obrázky stránek "
|
||||
"(a pod nimi textová vrstva, pokud existuje). "
|
||||
"Vyextrahujte data podle definovaného JSON tvaru.",
|
||||
}]
|
||||
text_parts = []
|
||||
for i, page in enumerate(doc):
|
||||
if i >= MAX_PAGES:
|
||||
break
|
||||
pix = page.get_pixmap(dpi=200, alpha=False)
|
||||
img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples)
|
||||
content.append({
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/png;base64,{_compress(img)}"},
|
||||
})
|
||||
txt = page.get_text("text") or ""
|
||||
if txt.strip():
|
||||
text_parts.append(f"--- strana {i + 1} ---\n{txt.strip()}")
|
||||
doc.close()
|
||||
if text_parts:
|
||||
content.append({
|
||||
"type": "text",
|
||||
"text": "Textová vrstva PDF:\n" + "\n\n".join(text_parts),
|
||||
})
|
||||
return content
|
||||
|
||||
|
||||
def _xlsx_content(path: Path) -> list[dict]:
|
||||
"""XLSX → a compact text dump of every sheet (rows as tab-joined cells)."""
|
||||
wb = openpyxl.load_workbook(str(path), data_only=True, read_only=True)
|
||||
blocks = []
|
||||
for ws in wb.worksheets:
|
||||
lines = [f"=== List: {ws.title} ==="]
|
||||
for r, row in enumerate(ws.iter_rows(values_only=True)):
|
||||
if r >= MAX_XLSX_ROWS:
|
||||
lines.append("… (zbytek listu vynechán)")
|
||||
break
|
||||
cells = ["" if c is None else str(c) for c in row]
|
||||
if any(c.strip() for c in cells):
|
||||
lines.append("\t".join(cells).rstrip())
|
||||
blocks.append("\n".join(lines))
|
||||
wb.close()
|
||||
dump = "\n\n".join(blocks)
|
||||
return [{
|
||||
"type": "text",
|
||||
"text": "Níže je obsah excelové dodavatelské nabídky (buňky listů). "
|
||||
"Vyextrahujte data podle definovaného JSON tvaru.\n\n" + dump,
|
||||
}]
|
||||
|
||||
|
||||
def _compress(img: Image.Image) -> str:
|
||||
if max(img.size) > MAX_IMG_LONG_EDGE:
|
||||
ratio = MAX_IMG_LONG_EDGE / max(img.size)
|
||||
img = img.resize((int(img.size[0] * ratio), int(img.size[1] * ratio)),
|
||||
Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
def _normalize(data: dict) -> dict:
|
||||
def _num(v):
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, (int, float)):
|
||||
return float(v)
|
||||
s = str(v).strip()
|
||||
# Czech number formatting: "1 289,40" → "1289.40"
|
||||
s = s.replace(" ", "").replace(" ", "").replace(",", ".")
|
||||
# strip trailing currency/units the model may have left in
|
||||
s = "".join(ch for ch in s if ch.isdigit() or ch in ".-")
|
||||
try:
|
||||
return float(s) if s not in ("", ".", "-") else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return {"is_offer": False, "supplier": {}, "line_items": []}
|
||||
|
||||
data.setdefault("is_offer", True)
|
||||
data.setdefault("supplier", {})
|
||||
data.setdefault("currency", "CZK")
|
||||
items = data.get("line_items") or []
|
||||
for item in items:
|
||||
for k in ("quantity", "unit_price", "total_price"):
|
||||
if k in item:
|
||||
item[k] = _num(item[k])
|
||||
# backfill total if missing but quantity & unit price present
|
||||
if item.get("total_price") is None and \
|
||||
item.get("quantity") is not None and \
|
||||
item.get("unit_price") is not None:
|
||||
item["total_price"] = round(item["quantity"] * item["unit_price"], 2)
|
||||
data["line_items"] = items
|
||||
return data
|
||||
251
offer-to-order/main.py
Normal file
251
offer-to-order/main.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""FastAPI: supplier offer (PDF/XLSX) → structured data → editable form → XLSX."""
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import base64
|
||||
|
||||
from fastapi import FastAPI, File, Form, Header, HTTPException, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
from datetime import date
|
||||
|
||||
from excel_export import write_offer_xlsx
|
||||
from extractor import extract_offer
|
||||
from order_form import build_order_pdf
|
||||
from store import (delete_history_value, delete_project, get_defaults,
|
||||
list_projects, next_order_number, remember_order_number,
|
||||
save_defaults, upsert_project)
|
||||
|
||||
# Buyer is always Colsys — fixed default for the order form's "Objednatel".
|
||||
OBJEDNATEL = {
|
||||
"name": "Colsys s.r.o.",
|
||||
"address": "Buštěhradská 109, 272 03 Kladno - Dubí",
|
||||
"ico": "14799634",
|
||||
"dic": "CZ14799634",
|
||||
}
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Offer to Order")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"],
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/offer-to-order"))
|
||||
WORK_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
jobs: dict[str, dict] = {}
|
||||
|
||||
ALLOWED_EXT = {".pdf", ".xlsx", ".xlsm", ".xls"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return FileResponse("static/index.html")
|
||||
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload(file: UploadFile = File(...)):
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_EXT:
|
||||
raise HTTPException(400, f"Podporované formáty: {', '.join(sorted(ALLOWED_EXT))}")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = WORK_DIR / job_id
|
||||
job_dir.mkdir()
|
||||
input_path = job_dir / f"input{suffix}"
|
||||
input_path.write_bytes(await file.read())
|
||||
logger.info("Job %s: %s (%d bytes)", job_id, file.filename,
|
||||
input_path.stat().st_size)
|
||||
|
||||
try:
|
||||
data = await extract_offer(input_path)
|
||||
except Exception as exc:
|
||||
logger.exception("Extraction failed")
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
jobs[job_id] = {
|
||||
"filename": file.filename,
|
||||
"job_dir": str(job_dir),
|
||||
"data": data,
|
||||
}
|
||||
return {"job_id": job_id, "data": data}
|
||||
|
||||
|
||||
class SaveRequest(BaseModel):
|
||||
data: dict
|
||||
|
||||
|
||||
@app.post("/api/save/{job_id}")
|
||||
async def save_data(job_id: str, req: SaveRequest):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Nenalezeno")
|
||||
jobs[job_id]["data"] = req.data
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/export/{job_id}")
|
||||
async def export(job_id: str):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Nenalezeno")
|
||||
job = jobs[job_id]
|
||||
out_path = Path(job["job_dir"]) / "nabidka.xlsx"
|
||||
write_offer_xlsx(job["data"], str(out_path))
|
||||
sup = (job["data"].get("supplier") or {}).get("name")
|
||||
base = sup or (Path(job["filename"]).stem if job.get("filename") else "nabidka")
|
||||
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(base))
|
||||
return FileResponse(
|
||||
str(out_path),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
filename=f"nabidka_{safe}.xlsx",
|
||||
)
|
||||
|
||||
|
||||
_NO_STORE = {"Cache-Control": "no-store"}
|
||||
|
||||
|
||||
@app.get("/api/order-defaults")
|
||||
async def order_defaults(code: str = ""):
|
||||
"""Remembered order-form fields for a given project code (Kód zakázky)."""
|
||||
return JSONResponse({"defaults": get_defaults(code)}, headers=_NO_STORE)
|
||||
|
||||
|
||||
class HistoryDeleteRequest(BaseModel):
|
||||
code: str
|
||||
field: str
|
||||
value: str
|
||||
|
||||
|
||||
@app.post("/api/order-history/delete")
|
||||
async def order_history_delete(req: HistoryDeleteRequest):
|
||||
delete_history_value(req.code, req.field, req.value)
|
||||
return {"defaults": get_defaults(req.code)}
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def projects():
|
||||
"""Reusable zakázky (Kód + Název) + the suggested next order number."""
|
||||
return JSONResponse({"projects": list_projects(),
|
||||
"next_order_number": next_order_number()},
|
||||
headers=_NO_STORE)
|
||||
|
||||
|
||||
class ProjectRequest(BaseModel):
|
||||
kod: str
|
||||
nazev: str = ""
|
||||
|
||||
|
||||
@app.post("/api/projects")
|
||||
async def project_upsert(req: ProjectRequest):
|
||||
if not req.kod.strip():
|
||||
raise HTTPException(400, "Kód zakázky je povinný")
|
||||
upsert_project(req.kod, req.nazev)
|
||||
return {"projects": list_projects()}
|
||||
|
||||
|
||||
@app.delete("/api/projects/{code}")
|
||||
async def project_delete(code: str):
|
||||
delete_project(code)
|
||||
return {"projects": list_projects()}
|
||||
|
||||
|
||||
class OrderPdfRequest(BaseModel):
|
||||
order: dict # the order-form fields (objednatel/dodavatel/terms/body)
|
||||
items: list | None = None # falls back to the job's extracted line_items
|
||||
|
||||
|
||||
@app.post("/api/order-pdf/{job_id}")
|
||||
async def order_pdf(job_id: str, req: OrderPdfRequest):
|
||||
if job_id not in jobs:
|
||||
raise HTTPException(404, "Nenalezeno")
|
||||
job = jobs[job_id]
|
||||
order = dict(req.order or {})
|
||||
order.setdefault("objednatel", OBJEDNATEL)
|
||||
order.setdefault("datum_vystaveni", date.today().strftime("%d.%m.%Y"))
|
||||
items = req.items if req.items is not None else (job["data"].get("line_items") or [])
|
||||
|
||||
# Remember the sticky fields for this project + the order number used
|
||||
# (so the next order is prefilled with this number + 1).
|
||||
save_defaults(order.get("kod_zakazky"), order)
|
||||
remember_order_number(order.get("objednavka_number"))
|
||||
|
||||
out_path = Path(job["job_dir"]) / "objednavka.pdf"
|
||||
build_order_pdf(order, items, str(out_path))
|
||||
num = order.get("objednavka_number") or order.get("kod_zakazky") or "objednavka"
|
||||
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(num))
|
||||
return FileResponse(str(out_path), media_type="application/pdf",
|
||||
filename=f"objednavka_{safe}.pdf")
|
||||
|
||||
|
||||
PROCESS_API_KEY = os.getenv("PROCESS_API_KEY", "")
|
||||
|
||||
|
||||
class ProcessRequest(BaseModel):
|
||||
filename: str # e.g. "nabidka.pdf"
|
||||
file_base64: str # the offer attachment, base64 (Power Automate contentBytes)
|
||||
|
||||
|
||||
@app.post("/api/process")
|
||||
async def process(req: ProcessRequest, x_api_key: str = Header(default="")):
|
||||
"""Unattended pipeline for Power Automate: offer attachment in →
|
||||
extracted items as the unified Excel out (base64)."""
|
||||
if not PROCESS_API_KEY:
|
||||
raise HTTPException(503, "PROCESS_API_KEY není nastaven na serveru")
|
||||
if x_api_key != PROCESS_API_KEY:
|
||||
raise HTTPException(401, "Neplatný API klíč")
|
||||
|
||||
suffix = Path(req.filename or "").suffix.lower()
|
||||
if suffix not in ALLOWED_EXT:
|
||||
raise HTTPException(400, f"Podporované formáty přílohy: {', '.join(sorted(ALLOWED_EXT))}")
|
||||
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = WORK_DIR / job_id
|
||||
job_dir.mkdir()
|
||||
input_path = job_dir / f"input{suffix}"
|
||||
try:
|
||||
input_path.write_bytes(base64.b64decode(req.file_base64))
|
||||
except Exception:
|
||||
raise HTTPException(400, "Přílohu se nepodařilo dekódovat (base64)")
|
||||
|
||||
try:
|
||||
data = await extract_offer(input_path)
|
||||
except Exception as exc:
|
||||
logger.exception("process: extraction failed")
|
||||
raise HTTPException(500, f"Extrakce přílohy selhala: {exc}")
|
||||
|
||||
items = data.get("line_items") or []
|
||||
total = sum(float(it.get("total_price") or 0) for it in items)
|
||||
xlsx_path = job_dir / "nabidka.xlsx"
|
||||
write_offer_xlsx(data, str(xlsx_path))
|
||||
|
||||
sup_name = (data.get("supplier") or {}).get("name") or "?"
|
||||
stem = sup_name if sup_name != "?" else Path(req.filename).stem
|
||||
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(stem))
|
||||
offer_no = data.get("offer_number") or ""
|
||||
return JSONResponse({
|
||||
"ok": True,
|
||||
"is_offer": data.get("is_offer", True),
|
||||
"offer_number": offer_no,
|
||||
"supplier_name": sup_name,
|
||||
"item_count": len(items),
|
||||
"total": round(total, 2),
|
||||
"summary": f"Dodavatel: {sup_name}; číslo nabídky: {offer_no or '-'}; "
|
||||
f"položek: {len(items)}; celkem bez DPH: {total:,.2f} Kč",
|
||||
"excel": {
|
||||
"filename": f"nabidka_{safe}.xlsx",
|
||||
"content_base64": base64.b64encode(xlsx_path.read_bytes()).decode(),
|
||||
},
|
||||
}, headers=_NO_STORE)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
330
offer-to-order/order_form.py
Normal file
330
offer-to-order/order_form.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Generate the Colsys 'Objednávka' order form as a PDF, replicating the
|
||||
ERP layout, followed by the line-items table. Output is one combined PDF.
|
||||
|
||||
Layout reference: samples/Objednavky_TU_2025_1576.PDF
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
PAGE_W, PAGE_H = A4 # 595.27 x 841.89 pt
|
||||
L = 40 # left margin
|
||||
R = PAGE_W - 40 # right margin
|
||||
|
||||
# ── Fonts (DejaVu = full Czech diacritics) ──
|
||||
_FONT, _BOLD = "Helvetica", "Helvetica-Bold"
|
||||
for name, path in (
|
||||
("DejaVu", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"),
|
||||
("DejaVu-Bold", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
):
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
pdfmetrics.registerFont(TTFont(name, path))
|
||||
except Exception:
|
||||
pass
|
||||
if "DejaVu" in pdfmetrics.getRegisteredFontNames():
|
||||
_FONT, _BOLD = "DejaVu", "DejaVu-Bold"
|
||||
|
||||
|
||||
def _ty(y: float) -> float:
|
||||
"""Top-origin → reportlab bottom-origin."""
|
||||
return PAGE_H - y
|
||||
|
||||
|
||||
def _wrap(c, text, font, size, max_w):
|
||||
text = str(text or "")
|
||||
if not text:
|
||||
return [""]
|
||||
out, line = [], ""
|
||||
for word in text.split(" "):
|
||||
trial = (line + " " + word).strip()
|
||||
if pdfmetrics.stringWidth(trial, font, size) <= max_w or not line:
|
||||
line = trial
|
||||
else:
|
||||
out.append(line)
|
||||
line = word
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def _money(v) -> str:
|
||||
if v is None or v == "":
|
||||
return ""
|
||||
try:
|
||||
s = f"{float(v):,.2f}"
|
||||
# CZ formatting: 1 234,56
|
||||
return s.replace(",", " ").replace(".", ",")
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
def _qty(v) -> str:
|
||||
if v is None or v == "":
|
||||
return ""
|
||||
try:
|
||||
f = float(v)
|
||||
return str(int(f)) if f.is_integer() else f"{f:.3f}".rstrip("0").rstrip(".").replace(".", ",")
|
||||
except (TypeError, ValueError):
|
||||
return str(v)
|
||||
|
||||
|
||||
# ─────────────────────────── page 1: the form ───────────────────────────
|
||||
|
||||
def _party_box(c, x0, x1, top, label, party):
|
||||
"""Draw one bordered party box (Objednatel / Dodavatel): the label on
|
||||
its own line, a blank row, then the company name + address below."""
|
||||
name = party.get("name") or ""
|
||||
addr = party.get("address") or ""
|
||||
parts = [p.strip() for p in addr.split(",") if p.strip()]
|
||||
ico, dic = party.get("ico") or "", party.get("dic") or ""
|
||||
email = party.get("email")
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(x0 + 6, _ty(top + 14), label)
|
||||
# blank row, then the company block on its own lines
|
||||
yy = top + 36
|
||||
c.setFont(_BOLD, 9.5)
|
||||
c.drawString(x0 + 6, _ty(yy), name)
|
||||
yy += 13
|
||||
c.setFont(_FONT, 8)
|
||||
for ln in parts:
|
||||
c.drawString(x0 + 6, _ty(yy), ln)
|
||||
yy += 11
|
||||
idline = " ".join(s for s in (f"IČO: {ico}" if ico else "",
|
||||
f"DIČ: {dic}" if dic else "") if s)
|
||||
if idline:
|
||||
c.drawString(x0 + 6, _ty(yy + 2), idline)
|
||||
yy += 13
|
||||
if email:
|
||||
c.drawString(x0 + 6, _ty(yy + 2), f"e-mail: {email}")
|
||||
yy += 12
|
||||
return yy
|
||||
|
||||
|
||||
def _kv_two(c, y, lx, ll, lv, rx, rl, rv):
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(lx, _ty(y), ll)
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(lx + 82, _ty(y), str(lv or ""))
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(rx, _ty(y), rl)
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(rx + 92, _ty(y), str(rv or ""))
|
||||
|
||||
|
||||
def _draw_form_page(c, o):
|
||||
objednatel = o.get("objednatel") or {}
|
||||
dodavatel = o.get("dodavatel") or {}
|
||||
# Terms may arrive flat on the order or nested under "terms".
|
||||
_terms = o.get("terms") or {}
|
||||
terms = {k: (o.get(k) if o.get(k) not in (None, "") else _terms.get(k))
|
||||
for k in ("zpusob_platby", "zpusob_odberu", "zpusob_dopravy",
|
||||
"forma_objednavky", "termin_dodani", "fakturujte")}
|
||||
num = o.get("objednavka_number") or ""
|
||||
|
||||
# Title (with top breathing room)
|
||||
c.setFont(_BOLD, 14)
|
||||
c.drawRightString(R, _ty(44), f"Objednávka: {num}")
|
||||
|
||||
# ── Header: two party boxes (with space above the boxes) ──
|
||||
top = 78
|
||||
box_h = 104
|
||||
mid = 300
|
||||
c.setLineWidth(0.6)
|
||||
c.rect(L, _ty(top + box_h), R - L, box_h, stroke=1, fill=0)
|
||||
c.line(mid, _ty(top + box_h), mid, _ty(top))
|
||||
_party_box(c, L, mid, top, "Objednatel", objednatel)
|
||||
_party_box(c, mid, R, top, "Dodavatel", dodavatel)
|
||||
|
||||
# ── Dodací a platební podmínky ──
|
||||
t2 = top + box_h
|
||||
h2 = 60
|
||||
c.rect(L, _ty(t2 + h2), R - L, h2, stroke=1, fill=0)
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(L + 4, _ty(t2 + 11), "Dodací a platební podmínky")
|
||||
yy = t2 + 26
|
||||
_kv_two(c, yy, L + 4, "Způsob platby:", terms.get("zpusob_platby"),
|
||||
mid + 4, "Forma objednávky:", terms.get("forma_objednavky"))
|
||||
_kv_two(c, yy + 12, L + 4, "Způsob odběru:", terms.get("zpusob_odberu") or "-",
|
||||
mid + 4, "Termín dodání:", terms.get("termin_dodani"))
|
||||
_kv_two(c, yy + 24, L + 4, "Způsob dopravy:", terms.get("zpusob_dopravy") or "-",
|
||||
mid + 4, "Fakturujte:", terms.get("fakturujte"))
|
||||
|
||||
# ── Datum vystavení / Popis ──
|
||||
t3 = t2 + h2
|
||||
h3 = 22
|
||||
c.rect(L, _ty(t3 + h3), R - L, h3, stroke=1, fill=0)
|
||||
c.line(mid, _ty(t3 + h3), mid, _ty(t3))
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(L + 4, _ty(t3 + 14), "Datum vystavení")
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(L + 95, _ty(t3 + 14), str(o.get("datum_vystaveni") or ""))
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(mid + 4, _ty(t3 + 14), "Popis:")
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(mid + 50, _ty(t3 + 14), str(o.get("popis") or ""))
|
||||
|
||||
# ── Sub-header: order no + page ──
|
||||
t4 = t3 + h3 + 16
|
||||
c.setFont(_BOLD, 9)
|
||||
c.drawString(L, _ty(t4), f"Objednávka: {num}")
|
||||
c.drawRightString(R, _ty(t4), "Strana: 1/1")
|
||||
c.setLineWidth(0.6)
|
||||
c.line(L, _ty(t4 + 4), R, _ty(t4 + 4))
|
||||
|
||||
# ── Body fields ──
|
||||
y = t4 + 22
|
||||
def field(label, value, gap_after=14, bold_label=True):
|
||||
nonlocal y
|
||||
c.setFont(_BOLD if bold_label else _FONT, 8.5)
|
||||
c.drawString(L, _ty(y), label)
|
||||
lw = pdfmetrics.stringWidth(label + " ", _BOLD, 8.5)
|
||||
c.setFont(_FONT, 8.5)
|
||||
for i, ln in enumerate(_wrap(c, value, _FONT, 8.5, R - L - lw)):
|
||||
c.drawString(L + (lw if i == 0 else 0), _ty(y), ln)
|
||||
if i > 0:
|
||||
y += 11
|
||||
y += gap_after
|
||||
|
||||
field("NÁZEV ZAKÁZKY:", o.get("nazev_zakazky"))
|
||||
field("KÓD ZAKÁZKY/NS:", o.get("kod_zakazky"), gap_after=20)
|
||||
field("TEXT:", o.get("text"))
|
||||
field("CENA CELKEM (bez DPH):", o.get("cena_celkem_text") or _money(o.get("cena_celkem")) + (" Kč" if o.get("cena_celkem") not in (None, "") else ""))
|
||||
field("TERMÍN DODÁNÍ:", o.get("termin_dodani_text"), gap_after=20)
|
||||
field("MÍSTO DODÁNÍ:", o.get("misto_dodani"))
|
||||
field("KONTAKTNÍ OSOBA V MÍSTĚ DODÁNÍ (jméno, tel, email):",
|
||||
o.get("kontaktni_osoba"), gap_after=20)
|
||||
field("SPLATNOST:", o.get("splatnost"))
|
||||
field("ZÁRUKA:", o.get("zaruka"))
|
||||
field("POZNÁMKA:", o.get("poznamka"))
|
||||
field("PŘÍLOHY:", o.get("prilohy"))
|
||||
|
||||
_draw_footer(c, o)
|
||||
|
||||
|
||||
def _draw_footer(c, o):
|
||||
fy = 135 # pt from the bottom of the page; block is drawn downward
|
||||
# Datum / Vystavil
|
||||
c.setFont(_FONT, 8)
|
||||
c.drawString(L, fy, "Datum:")
|
||||
c.drawString(360, fy, "Vystavil:")
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(L, fy - 12, str(o.get("datum_vystaveni") or ""))
|
||||
c.drawString(360, fy - 12, str(o.get("vystavil") or ""))
|
||||
c.setFont(_BOLD, 8)
|
||||
c.drawString(L, fy - 30, "Faktury zasílejte na email adresu: faktury@colsys.cz")
|
||||
legal = (
|
||||
"Dodavatel souhlasí a potvrzuje, že se seznámil jako: A) prodávající s "
|
||||
"Všeobecnými nákupními podmínkami (VNP), B) nebo jako zhotovitel s "
|
||||
"Všeobecnými smluvními podmínkami (VSP) společnosti Colsys s.r.o. platnými "
|
||||
"ke dni vystavení objednávky (viz http://www.colsys.cz/dokumenty-ke-stazeni). "
|
||||
"Ve fakturách uvádějte vždy číslo objednávky přesně (včetně velkých a malých "
|
||||
"písmen) a zasílejte je v elektronické formě emailem ve formátu isdoc nebo "
|
||||
"isdocx. Přílohou faktury je vždy odsouhlasený protokol o předání/převzetí "
|
||||
"(Dodací list, Předávací protokol, Zjišťovací protokol apod.)."
|
||||
)
|
||||
c.setFont(_FONT, 6.5)
|
||||
yy = fy - 44
|
||||
for ln in _wrap(c, legal, _FONT, 6.5, R - L):
|
||||
c.drawString(L, yy, ln)
|
||||
yy -= 8
|
||||
c.drawString(L, yy - 2,
|
||||
"Zapsáno: odd.C, vl. 902 obchodního rejstříku vedeného u Městského soudu v Praze.")
|
||||
|
||||
|
||||
# ─────────────────────────── items table ───────────────────────────
|
||||
|
||||
_COLS = [
|
||||
("Název zboží", "name", 200, "left"),
|
||||
("ID dodavatele", "supplier_id", 80, "left"),
|
||||
("Množství", "quantity", 55, "right"),
|
||||
("MJ", "unit", 35, "left"),
|
||||
("J. cena", "unit_price", 70, "right"),
|
||||
("Celkem", "total_price", 75, "right"),
|
||||
]
|
||||
|
||||
|
||||
def _draw_items(c, o, items):
|
||||
c.showPage()
|
||||
num = o.get("objednavka_number") or ""
|
||||
y = 50
|
||||
c.setFont(_BOLD, 12)
|
||||
c.drawString(L, _ty(y), "Položky objednávky")
|
||||
c.setFont(_FONT, 9)
|
||||
c.drawRightString(R, _ty(y), f"Objednávka: {num}")
|
||||
y += 22
|
||||
|
||||
def header():
|
||||
nonlocal y
|
||||
x = L
|
||||
c.setFont(_BOLD, 8)
|
||||
c.setFillColorRGB(1, 1, 1)
|
||||
c.setStrokeColorRGB(0.15, 0.39, 0.92)
|
||||
c.setFillColorRGB(0.15, 0.39, 0.92)
|
||||
c.rect(L, _ty(y + 14), R - L, 16, stroke=0, fill=1)
|
||||
c.setFillColorRGB(1, 1, 1)
|
||||
for title, _k, w, align in _COLS:
|
||||
if align == "right":
|
||||
c.drawRightString(x + w - 4, _ty(y + 10), title)
|
||||
else:
|
||||
c.drawString(x + 4, _ty(y + 10), title)
|
||||
x += w
|
||||
c.setFillColorRGB(0, 0, 0)
|
||||
y += 18
|
||||
|
||||
header()
|
||||
c.setFont(_FONT, 8)
|
||||
total = 0.0
|
||||
for it in items or []:
|
||||
# page break
|
||||
if y > PAGE_H - 80:
|
||||
c.showPage()
|
||||
y = 50
|
||||
header()
|
||||
c.setFont(_FONT, 8)
|
||||
name_lines = _wrap(c, it.get("name"), _FONT, 8, _COLS[0][2] - 6)
|
||||
row_h = max(12, len(name_lines) * 10 + 2)
|
||||
x = L
|
||||
for title, k, w, align in _COLS:
|
||||
if k == "name":
|
||||
for i, ln in enumerate(name_lines):
|
||||
c.drawString(x + 4, _ty(y + 9 + i * 10), ln)
|
||||
else:
|
||||
v = it.get(k)
|
||||
if k in ("unit_price", "total_price"):
|
||||
s = _money(v)
|
||||
elif k == "quantity":
|
||||
s = _qty(v)
|
||||
else:
|
||||
s = str(v or "")
|
||||
if align == "right":
|
||||
c.drawRightString(x + w - 4, _ty(y + 9), s)
|
||||
else:
|
||||
c.drawString(x + 4, _ty(y + 9), s)
|
||||
x += w
|
||||
c.setStrokeColorRGB(0.85, 0.85, 0.85)
|
||||
c.setLineWidth(0.4)
|
||||
c.line(L, _ty(y + row_h), R, _ty(y + row_h))
|
||||
y += row_h
|
||||
try:
|
||||
total += float(it.get("total_price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# total row
|
||||
y += 6
|
||||
c.setFont(_BOLD, 9)
|
||||
c.drawString(L + 4, _ty(y + 9), "Celkem bez DPH")
|
||||
c.drawRightString(R - 4, _ty(y + 9), _money(total) + " Kč")
|
||||
|
||||
|
||||
def build_order_pdf(order: dict, items: list, out_path: str) -> str:
|
||||
c = canvas.Canvas(out_path, pagesize=A4)
|
||||
_draw_form_page(c, order)
|
||||
_draw_items(c, order, items)
|
||||
c.save()
|
||||
return out_path
|
||||
65
offer-to-order/powerautomate/colsys-nabidky.swagger.json
Normal file
65
offer-to-order/powerautomate/colsys-nabidky.swagger.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "Colsys – Nabídka → Excel",
|
||||
"description": "Zpracuje dodavatelskou nabídku (PDF/XLSX) a vrátí Excel s položkami.",
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "ai.klas.chat",
|
||||
"basePath": "/apps/nabidky",
|
||||
"schemes": ["https"],
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
"securityDefinitions": {
|
||||
"apiKeyHeader": { "type": "apiKey", "in": "header", "name": "X-API-Key" }
|
||||
},
|
||||
"security": [ { "apiKeyHeader": [] } ],
|
||||
"paths": {
|
||||
"/api/process": {
|
||||
"post": {
|
||||
"summary": "Zpracovat nabídku",
|
||||
"description": "Pošle přílohu nabídky a vrátí Excel.",
|
||||
"operationId": "ProcessOffer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"required": ["filename", "file_base64"],
|
||||
"properties": {
|
||||
"filename": { "type": "string", "x-ms-summary": "Název souboru", "description": "Např. nabidka.pdf" },
|
||||
"file_base64": { "type": "string", "x-ms-summary": "Obsah souboru (base64)", "description": "ContentBytes přílohy" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ok": { "type": "boolean", "x-ms-summary": "OK" },
|
||||
"offer_number": { "type": "string", "x-ms-summary": "Číslo nabídky" },
|
||||
"supplier_name": { "type": "string", "x-ms-summary": "Dodavatel" },
|
||||
"item_count": { "type": "integer", "x-ms-summary": "Počet položek" },
|
||||
"total": { "type": "number", "x-ms-summary": "Celkem bez DPH" },
|
||||
"summary": { "type": "string", "x-ms-summary": "Shrnutí" },
|
||||
"excel": {
|
||||
"type": "object",
|
||||
"x-ms-summary": "Excel",
|
||||
"properties": {
|
||||
"filename": { "type": "string", "x-ms-summary": "Název Excelu" },
|
||||
"content_base64": { "type": "string", "x-ms-summary": "Obsah Excelu (base64)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
offer-to-order/requirements.txt
Normal file
9
offer-to-order/requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
PyMuPDF>=1.24
|
||||
Pillow>=10.0
|
||||
openpyxl>=3.1
|
||||
python-multipart>=0.0.9
|
||||
openai>=1.50
|
||||
python-dotenv>=1.0
|
||||
reportlab>=4.0
|
||||
635
offer-to-order/static/app.js
Normal file
635
offer-to-order/static/app.js
Normal file
@@ -0,0 +1,635 @@
|
||||
// Offer → Excel frontend
|
||||
(() => {
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const sections = {
|
||||
upload: $("s-upload"),
|
||||
processing: $("s-processing"),
|
||||
result: $("s-result"),
|
||||
};
|
||||
const show = (name) => {
|
||||
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
|
||||
};
|
||||
|
||||
let state = { jobId: null, data: null };
|
||||
|
||||
// ── Upload ──
|
||||
const fileInput = $("file-input");
|
||||
const dropZone = $("drop-zone");
|
||||
$("browse-btn").addEventListener("click", () => fileInput.click());
|
||||
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0]));
|
||||
["dragenter", "dragover"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
|
||||
["dragleave", "drop"].forEach((ev) =>
|
||||
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
|
||||
dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
|
||||
|
||||
// Close any open combobox menu when clicking outside it (bound once).
|
||||
document.addEventListener("click", (e) => {
|
||||
document.querySelectorAll(".combo-menu").forEach((m) => {
|
||||
if (!m.closest(".combo-wrap").contains(e.target)) m.classList.add("hidden");
|
||||
});
|
||||
});
|
||||
|
||||
async function upload(file) {
|
||||
show("processing");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const r = await fetch("api/upload", { method: "POST", body: fd });
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const json = await r.json();
|
||||
state.jobId = json.job_id;
|
||||
state.data = json.data || {};
|
||||
await initOrder();
|
||||
renderForm();
|
||||
show("result");
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
show("upload");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render editable form ──
|
||||
function nestedField(parent, key, label, value, opts = {}) {
|
||||
const isEmpty = value === null || value === undefined || value === "";
|
||||
const cls = "inv-input" + (isEmpty ? " is-empty" : "");
|
||||
return `
|
||||
<div class="inv-field ${opts.wide ? "inv-field-wide" : ""}">
|
||||
<label class="inv-label">${escapeHtml(label)}</label>
|
||||
<input class="${cls}" data-parent="${parent}" data-key="${key}"
|
||||
type="text" value="${escapeHtmlAttr(isEmpty ? "" : value)}"
|
||||
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
const d = state.data;
|
||||
const sup = d.supplier || {};
|
||||
const items = d.line_items || [];
|
||||
|
||||
const warn = (d.is_offer === false)
|
||||
? `<div class="offer-warn">⚠️ Tento dokument zřejmě není dodavatelská
|
||||
nabídka. Data mohou být neúplná — zkontrolujte je, nebo nahrajte
|
||||
jiný soubor.</div>`
|
||||
: "";
|
||||
|
||||
const html = `
|
||||
${warn}
|
||||
<!-- Dodavatel -->
|
||||
<div class="inv-card">
|
||||
<div class="inv-card-title">Dodavatel</div>
|
||||
<div class="inv-grid">
|
||||
${nestedField("supplier", "name", "Jméno", sup.name, {wide: true})}
|
||||
${nestedField("supplier", "ico", "IČO", sup.ico)}
|
||||
${nestedField("supplier", "dic", "DIČ", sup.dic)}
|
||||
${nestedField("supplier", "email", "Kontaktní email", sup.email)}
|
||||
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Položky -->
|
||||
<div class="inv-card">
|
||||
<div class="inv-card-title">Položky (${items.length})</div>
|
||||
<div class="inv-table-wrap">
|
||||
<table class="inv-table" id="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="min-width:220px">Název zboží</th>
|
||||
<th style="width:150px">ID zboží dodavatele</th>
|
||||
<th style="width:90px">Množství</th>
|
||||
<th style="width:80px">Jednotka</th>
|
||||
<th style="width:120px">Jednotková cena</th>
|
||||
<th style="width:120px">Cena celkem</th>
|
||||
<th class="inv-row-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="items-body">
|
||||
${items.map((it, i) => renderItemRow(it, i)).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="inv-table-foot">
|
||||
<button class="btn-add-row" type="button" id="add-item-btn">+ Přidat položku</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="order-card-wrap">${orderCardHtml()}</div>
|
||||
`;
|
||||
$("offer-form").innerHTML = html;
|
||||
bindFieldHandlers();
|
||||
bindOrderHandlers();
|
||||
initDatePickers();
|
||||
}
|
||||
|
||||
function rerenderOrderCard() {
|
||||
const wrap = $("order-card-wrap");
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = orderCardHtml();
|
||||
bindOrderHandlers();
|
||||
initDatePickers();
|
||||
}
|
||||
|
||||
function initDatePickers() {
|
||||
if (typeof flatpickr === "undefined") return;
|
||||
if (flatpickr.l10ns && flatpickr.l10ns.cs) flatpickr.localize(flatpickr.l10ns.cs);
|
||||
document.querySelectorAll(".fp-date").forEach((el) => {
|
||||
if (el._flatpickr) return;
|
||||
flatpickr(el, {
|
||||
dateFormat: "d.m.Y", // matches the PDF / store format
|
||||
allowInput: true,
|
||||
disableMobile: true,
|
||||
onChange: () => {
|
||||
state.order[el.dataset.order] = el.value;
|
||||
el.classList.toggle("is-empty", el.value === "");
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderItemRow(item, i) {
|
||||
return `
|
||||
<tr data-row="${i}">
|
||||
<td><input data-field="name" type="text" value="${escapeHtmlAttr(item.name || "")}"></td>
|
||||
<td><input data-field="supplier_id" type="text" value="${escapeHtmlAttr(item.supplier_id || "")}"></td>
|
||||
<td class="num"><input data-field="quantity" type="text" value="${fmtNum(item.quantity)}"></td>
|
||||
<td><input data-field="unit" type="text" value="${escapeHtmlAttr(item.unit || "")}"></td>
|
||||
<td class="num"><input data-field="unit_price" type="text" value="${fmtNum(item.unit_price)}"></td>
|
||||
<td class="num"><input data-field="total_price" type="text" value="${fmtNum(item.total_price)}"></td>
|
||||
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function bindFieldHandlers() {
|
||||
$("offer-form").querySelectorAll("input[data-key]").forEach((el) => {
|
||||
el.addEventListener("input", () => {
|
||||
const key = el.dataset.key;
|
||||
const parent = el.dataset.parent;
|
||||
if (parent) {
|
||||
if (!state.data[parent]) state.data[parent] = {};
|
||||
state.data[parent][key] = el.value;
|
||||
} else {
|
||||
state.data[key] = el.value;
|
||||
}
|
||||
el.classList.toggle("is-empty", el.value === "");
|
||||
});
|
||||
});
|
||||
|
||||
const itemsBody = $("items-body");
|
||||
if (itemsBody) {
|
||||
itemsBody.addEventListener("input", (e) => {
|
||||
const inp = e.target.closest("input");
|
||||
if (!inp) return;
|
||||
const i = +inp.closest("tr").dataset.row;
|
||||
const field = inp.dataset.field;
|
||||
if (!state.data.line_items) state.data.line_items = [];
|
||||
if (!state.data.line_items[i]) state.data.line_items[i] = {};
|
||||
const numFields = ["quantity", "unit_price", "total_price"];
|
||||
state.data.line_items[i][field] =
|
||||
numFields.includes(field) ? parseNum(inp.value) : inp.value;
|
||||
});
|
||||
itemsBody.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest('[data-action="delete-item"]');
|
||||
if (!btn) return;
|
||||
const i = +btn.closest("tr").dataset.row;
|
||||
state.data.line_items.splice(i, 1);
|
||||
renderForm();
|
||||
});
|
||||
}
|
||||
const addItem = $("add-item-btn");
|
||||
if (addItem) {
|
||||
addItem.addEventListener("click", () => {
|
||||
if (!state.data.line_items) state.data.line_items = [];
|
||||
state.data.line_items.push({name: "", supplier_id: "", quantity: null,
|
||||
unit: "", unit_price: null, total_price: null});
|
||||
renderForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseNum(s) {
|
||||
if (s === "" || s == null) return null;
|
||||
const n = parseFloat(String(s).replace(",", ".").replace(/\s/g, ""));
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
function fmtNum(v) {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ── Export ──
|
||||
$("export-btn").addEventListener("click", async () => {
|
||||
if (!state.jobId) return;
|
||||
await fetch(`api/save/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({data: state.data}),
|
||||
}).catch(() => {});
|
||||
window.location.href = `api/export/${state.jobId}`;
|
||||
});
|
||||
|
||||
$("restart-btn").addEventListener("click", () => {
|
||||
state = { jobId: null, data: null };
|
||||
fileInput.value = "";
|
||||
$("offer-form").innerHTML = "";
|
||||
show("upload");
|
||||
});
|
||||
|
||||
// ── Objednávka (order form) ──────────────────────────────────
|
||||
function sumTotals() {
|
||||
return (state.data.line_items || []).reduce(
|
||||
(a, it) => a + (Number(it.total_price) || 0), 0);
|
||||
}
|
||||
|
||||
async function initOrder() {
|
||||
const d = state.data;
|
||||
const offerNo = d.offer_number || "";
|
||||
state.order = {
|
||||
objednavka_number: "",
|
||||
kod_zakazky: "",
|
||||
nazev_zakazky: "",
|
||||
dodavatel: d.supplier || {},
|
||||
text: "Objednáváme u vás zboží dle cenové nabídky " + offerNo,
|
||||
prilohy: offerNo,
|
||||
termin_dodani_text: "Co nejdříve",
|
||||
misto_dodani: "",
|
||||
kontaktni_osoba: "",
|
||||
splatnost: "dle VNP nebo VSP",
|
||||
zaruka: "",
|
||||
poznamka: "",
|
||||
vystavil: "",
|
||||
popis: "",
|
||||
zpusob_platby: "Bankovním převodem",
|
||||
zpusob_odberu: "-",
|
||||
zpusob_dopravy: "-",
|
||||
forma_objednavky: "mailem",
|
||||
termin_dodani: "",
|
||||
fakturujte: "s DPH",
|
||||
};
|
||||
state.projects = [];
|
||||
state.orderHistory = {};
|
||||
try {
|
||||
const r = await fetch("api/projects");
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
state.projects = j.projects || [];
|
||||
if (j.next_order_number) state.order.objednavka_number = j.next_order_number;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Body fields. `hist` = remembered per project, shown with a history
|
||||
// quick-pick (most-used first).
|
||||
const ORDER_FIELDS = [
|
||||
["text", "Text", true, false],
|
||||
["termin_dodani_text", "Termín dodání (text)", false, false],
|
||||
["misto_dodani", "Místo dodání", true, true],
|
||||
["kontaktni_osoba", "Kontaktní osoba v místě dodání", true, true],
|
||||
["splatnost", "Splatnost", false, true],
|
||||
["zaruka", "Záruka", false, true],
|
||||
["poznamka", "Poznámka", false, false],
|
||||
["prilohy", "Přílohy", false, false],
|
||||
["vystavil", "Vystavil", false, true],
|
||||
["popis", "Popis", false, true],
|
||||
];
|
||||
|
||||
function fieldHtml(key, label, wide, hist) {
|
||||
return hist ? historyField(key, label, wide) : textField(key, label, wide);
|
||||
}
|
||||
|
||||
// ── date helpers (PDF/store use DD.MM.YYYY, <input type=date> uses ISO) ──
|
||||
function toDateInput(s) {
|
||||
s = (s == null ? "" : String(s)).trim();
|
||||
let m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
if (m) return `${m[3]}-${m[2].padStart(2,"0")}-${m[1].padStart(2,"0")}`;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
return "";
|
||||
}
|
||||
function fromDateInput(s) {
|
||||
const m = (s || "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
return m ? `${+m[3]}.${+m[2]}.${m[1]}` : (s || "");
|
||||
}
|
||||
|
||||
// Combobox: type freely OR pick a previously-used value from a themed
|
||||
// dropdown attached to the field; each option can be deleted from history.
|
||||
function comboMenuHtml(key, filter) {
|
||||
const hist = (state.orderHistory && state.orderHistory[key]) || [];
|
||||
const f = (filter || "").toLowerCase();
|
||||
const items = hist.filter((h) => !f || String(h.value).toLowerCase().includes(f));
|
||||
if (!items.length) return '<div class="combo-empty">Žádné dříve použité</div>';
|
||||
return items.map((h) => `
|
||||
<div class="combo-opt" data-val="${escapeHtmlAttr(h.value)}">
|
||||
<span class="combo-opt-text">${escapeHtml(h.value)}
|
||||
<span class="combo-opt-count">${h.count}×</span></span>
|
||||
<button class="combo-del" type="button" title="Smazat z historie">×</button>
|
||||
</div>`).join("");
|
||||
}
|
||||
|
||||
function historyField(key, label, wide) {
|
||||
const v = state.order[key];
|
||||
const cur = v == null ? "" : String(v);
|
||||
const hasHist = ((state.orderHistory && state.orderHistory[key]) || []).length > 0;
|
||||
return `
|
||||
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
|
||||
<label class="inv-label">${escapeHtml(label)}</label>
|
||||
<div class="combo-wrap" data-combo="${key}">
|
||||
<input class="inv-input combo-input${cur === "" ? " is-empty" : ""}" data-order="${key}"
|
||||
type="text" autocomplete="off" value="${escapeHtmlAttr(cur)}">
|
||||
${hasHist ? '<button class="combo-toggle" type="button" tabindex="-1" aria-label="Dříve použité">⌄</button>' : ""}
|
||||
<div class="combo-menu hidden">${comboMenuHtml(key)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Dodací a platební podmínky — selectable presets (editable: type or pick).
|
||||
const TERMS_FIELDS = [
|
||||
["zpusob_platby", "Způsob platby", ["Bankovním převodem", "Hotově", "Kartou", "Zálohově", "Dobírka"]],
|
||||
["forma_objednavky", "Forma objednávky", ["mailem", "telefonicky", "osobně", "písemně"]],
|
||||
["zpusob_odberu", "Způsob odběru", ["-", "Osobní odběr", "Dodání na stavbu", "Dopravcem"]],
|
||||
["termin_dodani", "Termín dodání", []],
|
||||
["zpusob_dopravy", "Způsob dopravy", ["-", "Dodavatel", "Vlastní", "Dopravce"]],
|
||||
["fakturujte", "Fakturujte", ["s DPH", "bez DPH"]],
|
||||
];
|
||||
|
||||
function textField(key, label, wide) {
|
||||
const v = state.order[key];
|
||||
const isEmpty = v === null || v === undefined || v === "";
|
||||
return `
|
||||
<div class="inv-field ${wide ? "inv-field-wide" : ""}">
|
||||
<label class="inv-label">${escapeHtml(label)}</label>
|
||||
<input class="inv-input${isEmpty ? " is-empty" : ""}" data-order="${key}"
|
||||
type="text" value="${escapeHtmlAttr(isEmpty ? "" : v)}">
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function comboField(key, label, options) {
|
||||
const v = state.order[key];
|
||||
const cur = v == null ? "" : String(v);
|
||||
// Termín dodání → flatpickr date picker (value stays DD.MM.YYYY).
|
||||
if (key === "termin_dodani") {
|
||||
return `
|
||||
<div class="inv-field">
|
||||
<label class="inv-label">${escapeHtml(label)}</label>
|
||||
<input class="inv-input fp-date${cur === "" ? " is-empty" : ""}" data-order="${key}"
|
||||
type="text" placeholder="dd.mm.rrrr" value="${escapeHtmlAttr(cur)}">
|
||||
</div>`;
|
||||
}
|
||||
// Real dropdown: history (most-used first) + presets + any custom value.
|
||||
const histVals = ((state.orderHistory && state.orderHistory[key]) || [])
|
||||
.map((h) => h.value);
|
||||
const vals = [];
|
||||
for (const x of [...histVals, ...(options || []), cur]) {
|
||||
if (x && !vals.includes(x)) vals.push(x);
|
||||
}
|
||||
const opts = ['<option value=""></option>']
|
||||
.concat(vals.map((o) =>
|
||||
`<option value="${escapeHtmlAttr(o)}"${o === cur ? " selected" : ""}>${escapeHtml(o)}</option>`))
|
||||
.join("");
|
||||
return `
|
||||
<div class="inv-field">
|
||||
<label class="inv-label">${escapeHtml(label)}</label>
|
||||
<select class="inv-input" data-order="${key}">${opts}</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function projectOptions() {
|
||||
const cur = state.order.kod_zakazky || "";
|
||||
const opts = ['<option value="">— vyberte zakázku —</option>'];
|
||||
for (const p of state.projects || []) {
|
||||
const sel = p.kod === cur ? " selected" : "";
|
||||
const label = p.nazev ? `${p.kod} — ${p.nazev}` : p.kod;
|
||||
opts.push(`<option value="${escapeHtmlAttr(p.kod)}"${sel}>${escapeHtml(label)}</option>`);
|
||||
}
|
||||
return opts.join("");
|
||||
}
|
||||
|
||||
function orderCardHtml() {
|
||||
return `
|
||||
<div class="inv-card">
|
||||
<div class="inv-card-title">Objednávka (vyplní se do PDF)</div>
|
||||
<p class="order-hint">Zakázku vyberte z nabídky (nebo přidejte přes
|
||||
<strong>Spravovat zakázky</strong>). Pole jako <strong>Místo dodání</strong>,
|
||||
kontaktní osoba či Vystavil se pamatují podle zakázky. Číslo objednávky
|
||||
se předvyplní jako poslední + 1. Cena celkem z položek:
|
||||
${fmtCzk(sumTotals())}.</p>
|
||||
<div class="inv-grid">
|
||||
${textField("objednavka_number", "Číslo objednávky", false)}
|
||||
<div class="inv-field">
|
||||
<label class="inv-label">Kód zakázky / NS</label>
|
||||
<div class="proj-row">
|
||||
<select class="inv-input" id="proj-select">${projectOptions()}</select>
|
||||
<button class="btn btn-secondary btn-sm" id="proj-manage" type="button">Spravovat</button>
|
||||
</div>
|
||||
</div>
|
||||
${textField("nazev_zakazky", "Název zakázky", true)}
|
||||
${ORDER_FIELDS.map(([k, l, w, h]) => fieldHtml(k, l, w, h)).join("")}
|
||||
</div>
|
||||
<div class="terms-subtitle">Dodací a platební podmínky</div>
|
||||
<div class="inv-grid">
|
||||
${TERMS_FIELDS.map(([k, l, o]) => comboField(k, l, o)).join("")}
|
||||
</div>
|
||||
<div class="proj-panel hidden" id="proj-panel"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmtCzk(n) {
|
||||
return (Number(n) || 0).toLocaleString("cs-CZ", {minimumFractionDigits: 2, maximumFractionDigits: 2}) + " Kč";
|
||||
}
|
||||
|
||||
function applyOrderToInputs() {
|
||||
document.querySelectorAll('[data-order]').forEach((inp) => {
|
||||
const k = inp.dataset.order;
|
||||
if (state.order[k] != null) {
|
||||
inp.value = state.order[k];
|
||||
inp.classList.toggle("is-empty", inp.value === "");
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('[data-order-date]').forEach((inp) => {
|
||||
const k = inp.dataset.orderDate;
|
||||
inp.value = toDateInput(state.order[k]);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProjectDefaults(code) {
|
||||
if (!code) { state.orderHistory = {}; return; }
|
||||
try {
|
||||
const r = await fetch(`api/order-defaults?code=${encodeURIComponent(code)}`);
|
||||
if (!r.ok) return;
|
||||
const { defaults } = await r.json();
|
||||
if (defaults && Object.keys(defaults).length) {
|
||||
state.orderHistory = defaults._history || {};
|
||||
delete defaults._history;
|
||||
Object.assign(state.order, defaults);
|
||||
// Re-render so history dropdowns reflect the selected project.
|
||||
rerenderOrderCard();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function renderProjPanel() {
|
||||
const rows = (state.projects || []).map((p) => `
|
||||
<div class="proj-item" data-kod="${escapeHtmlAttr(p.kod)}">
|
||||
<span class="proj-kod">${escapeHtml(p.kod)}</span>
|
||||
<input class="inv-input proj-nazev" value="${escapeHtmlAttr(p.nazev || "")}" placeholder="Název zakázky">
|
||||
<button class="btn btn-secondary btn-sm proj-save" type="button">Uložit</button>
|
||||
<button class="btn btn-secondary btn-sm proj-del" type="button">Smazat</button>
|
||||
</div>`).join("");
|
||||
return `
|
||||
<div class="proj-add">
|
||||
<input class="inv-input" id="proj-new-kod" placeholder="Číslo zakázky (kód/NS)">
|
||||
<input class="inv-input" id="proj-new-nazev" placeholder="Název zakázky">
|
||||
<button class="btn btn-primary btn-sm" id="proj-add-btn" type="button">Přidat</button>
|
||||
</div>
|
||||
<div class="proj-list">${rows || '<p class="order-hint">Zatím žádné zakázky.</p>'}</div>`;
|
||||
}
|
||||
|
||||
async function refreshProjects(list) {
|
||||
state.projects = list || state.projects;
|
||||
const sel = $("proj-select");
|
||||
if (sel) sel.innerHTML = projectOptions();
|
||||
const panel = $("proj-panel");
|
||||
if (panel && !panel.classList.contains("hidden")) panel.innerHTML = renderProjPanel();
|
||||
}
|
||||
|
||||
function bindOrderHandlers() {
|
||||
document.querySelectorAll('[data-order]').forEach((el) => {
|
||||
const upd = () => {
|
||||
state.order[el.dataset.order] = el.value;
|
||||
el.classList.toggle("is-empty", el.value === "");
|
||||
};
|
||||
el.addEventListener("input", upd);
|
||||
el.addEventListener("change", upd); // <select> fires change
|
||||
});
|
||||
// Date pickers store DD.MM.YYYY in the order.
|
||||
document.querySelectorAll('[data-order-date]').forEach((el) => {
|
||||
el.addEventListener("change", () => {
|
||||
state.order[el.dataset.orderDate] = fromDateInput(el.value);
|
||||
el.classList.toggle("is-empty", el.value === "");
|
||||
});
|
||||
});
|
||||
// Combobox fields: open menu on focus/toggle, filter on type, pick or
|
||||
// delete a previously-used value.
|
||||
document.querySelectorAll(".combo-wrap").forEach((wrap) => {
|
||||
const key = wrap.dataset.combo;
|
||||
const input = wrap.querySelector(".combo-input");
|
||||
const menu = wrap.querySelector(".combo-menu");
|
||||
const toggle = wrap.querySelector(".combo-toggle");
|
||||
const open = () => { menu.innerHTML = comboMenuHtml(key, input.value); menu.classList.remove("hidden"); };
|
||||
const close = () => menu.classList.add("hidden");
|
||||
if (toggle) toggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.contains("hidden") ? open() : close();
|
||||
});
|
||||
input.addEventListener("focus", open);
|
||||
input.addEventListener("input", open); // value already synced by generic handler
|
||||
menu.addEventListener("click", async (e) => {
|
||||
const del = e.target.closest(".combo-del");
|
||||
if (del) {
|
||||
e.stopPropagation();
|
||||
const val = del.closest(".combo-opt").dataset.val;
|
||||
const code = state.order.kod_zakazky;
|
||||
if (!code) { menu.querySelector(`[data-val="${CSS.escape(val)}"]`)?.remove(); return; }
|
||||
try {
|
||||
const r = await fetch("api/order-history/delete", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ code, field: key, value: val }),
|
||||
});
|
||||
if (r.ok) {
|
||||
state.orderHistory = (await r.json()).defaults._history || {};
|
||||
menu.innerHTML = comboMenuHtml(key, input.value);
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
const opt = e.target.closest(".combo-opt");
|
||||
if (opt) {
|
||||
input.value = opt.dataset.val;
|
||||
input.classList.remove("is-empty");
|
||||
state.order[key] = opt.dataset.val;
|
||||
close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const sel = $("proj-select");
|
||||
if (sel) sel.addEventListener("change", async () => {
|
||||
const code = sel.value;
|
||||
state.order.kod_zakazky = code;
|
||||
const p = (state.projects || []).find((x) => x.kod === code);
|
||||
state.order.nazev_zakazky = p ? (p.nazev || "") : "";
|
||||
applyOrderToInputs();
|
||||
await loadProjectDefaults(code);
|
||||
});
|
||||
|
||||
const manage = $("proj-manage");
|
||||
if (manage) manage.addEventListener("click", () => {
|
||||
const panel = $("proj-panel");
|
||||
panel.classList.toggle("hidden");
|
||||
if (!panel.classList.contains("hidden")) {
|
||||
panel.innerHTML = renderProjPanel();
|
||||
panel.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
});
|
||||
|
||||
const panel = $("proj-panel");
|
||||
if (panel) {
|
||||
panel.addEventListener("click", async (e) => {
|
||||
const addBtn = e.target.closest("#proj-add-btn");
|
||||
if (addBtn) {
|
||||
const kod = $("proj-new-kod").value.trim();
|
||||
const nazev = $("proj-new-nazev").value.trim();
|
||||
if (!kod) { alert("Zadejte číslo zakázky."); return; }
|
||||
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
|
||||
if (r.ok) { refreshProjects((await r.json()).projects); }
|
||||
return;
|
||||
}
|
||||
const item = e.target.closest(".proj-item");
|
||||
if (!item) return;
|
||||
const kod = item.dataset.kod;
|
||||
if (e.target.closest(".proj-save")) {
|
||||
const nazev = item.querySelector(".proj-nazev").value.trim();
|
||||
const r = await fetch("api/projects", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({kod, nazev})});
|
||||
if (r.ok) refreshProjects((await r.json()).projects);
|
||||
} else if (e.target.closest(".proj-del")) {
|
||||
if (!confirm(`Smazat zakázku ${kod}?`)) return;
|
||||
const r = await fetch(`api/projects/${encodeURIComponent(kod)}`, {method:"DELETE"});
|
||||
if (r.ok) refreshProjects((await r.json()).projects);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$("order-pdf-btn").addEventListener("click", async () => {
|
||||
if (!state.jobId) return;
|
||||
const btn = $("order-pdf-btn");
|
||||
const old = btn.textContent;
|
||||
btn.disabled = true; btn.textContent = "Generuji…";
|
||||
try {
|
||||
// keep supplier in sync as dodavatel
|
||||
state.order.dodavatel = state.data.supplier || {};
|
||||
state.order.cena_celkem = sumTotals();
|
||||
const r = await fetch(`api/order-pdf/${state.jobId}`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ order: state.order, items: state.data.line_items || [] }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
|
||||
const cd = r.headers.get("content-disposition") || "";
|
||||
const m = cd.match(/filename="?([^"]+)"?/);
|
||||
const blob = await r.blob();
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = m ? m[1] : "objednavka.pdf";
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
|
||||
} catch (err) {
|
||||
alert("Chyba: " + err.message);
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = old;
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
function escapeHtmlAttr(s) { return escapeHtml(s); }
|
||||
})();
|
||||
325
offer-to-order/static/extra.css
Normal file
325
offer-to-order/static/extra.css
Normal file
@@ -0,0 +1,325 @@
|
||||
/* Invoice-extractor specific styles */
|
||||
|
||||
.processing-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 8px auto 0;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.invoice-form,
|
||||
.offer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.order-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-tertiary);
|
||||
margin: -2px 0 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
/* Combobox (type or pick previously-used value, with per-item delete) */
|
||||
.combo-wrap { position: relative; }
|
||||
/* Transparent click target over the chevron (drawn by the input background). */
|
||||
.combo-toggle {
|
||||
position: absolute; right: 0; top: 0; bottom: 0; width: 30px;
|
||||
border: none; background: transparent; cursor: pointer; padding: 0;
|
||||
font-size: 0; color: transparent;
|
||||
}
|
||||
.combo-menu {
|
||||
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 60;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-card, 0 8px 28px rgba(0,0,0,.18));
|
||||
max-height: 220px; overflow-y: auto; padding: 4px;
|
||||
}
|
||||
.combo-opt {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
padding: 6px 8px; border-radius: 5px; cursor: pointer;
|
||||
font-size: 13.5px; color: var(--text-primary);
|
||||
}
|
||||
.combo-opt:hover { background: var(--bg-secondary); }
|
||||
.combo-opt-count { color: var(--text-tertiary); font-size: 12px; margin-left: 4px; }
|
||||
.combo-del {
|
||||
border: none; background: transparent; color: var(--text-tertiary);
|
||||
cursor: pointer; font-size: 17px; line-height: 1; padding: 0 4px; border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.combo-del:hover { color: var(--danger, #c0392b); background: var(--bg-secondary); }
|
||||
.combo-empty { padding: 8px; color: var(--text-tertiary); font-size: 13px; }
|
||||
/* ── flatpickr date picker — themed to the page ── */
|
||||
.flatpickr-calendar {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-card, 0 8px 28px rgba(0,0,0,.18));
|
||||
font-family: inherit;
|
||||
}
|
||||
.flatpickr-calendar.arrowTop::before,
|
||||
.flatpickr-calendar.arrowTop::after { border-bottom-color: var(--bg-primary); }
|
||||
.flatpickr-calendar.arrowBottom::before,
|
||||
.flatpickr-calendar.arrowBottom::after { border-top-color: var(--bg-primary); }
|
||||
.flatpickr-months,
|
||||
.flatpickr-month,
|
||||
.flatpickr-current-month input.cur-year,
|
||||
.flatpickr-weekdays,
|
||||
span.flatpickr-weekday {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
fill: var(--text-secondary);
|
||||
}
|
||||
.flatpickr-current-month .flatpickr-monthDropdown-months { background: var(--bg-primary); }
|
||||
.flatpickr-months .flatpickr-month { color: var(--text-primary); fill: var(--text-primary); }
|
||||
.flatpickr-months .flatpickr-prev-month svg,
|
||||
.flatpickr-months .flatpickr-next-month svg { fill: var(--text-secondary); }
|
||||
.flatpickr-months .flatpickr-prev-month:hover svg,
|
||||
.flatpickr-months .flatpickr-next-month:hover svg { fill: var(--primary); }
|
||||
.flatpickr-day {
|
||||
color: var(--text-primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.flatpickr-day:hover,
|
||||
.flatpickr-day:focus { background: var(--bg-secondary); border-color: var(--bg-secondary); }
|
||||
.flatpickr-day.today { border-color: var(--primary); }
|
||||
.flatpickr-day.selected,
|
||||
.flatpickr-day.selected:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.flatpickr-day.flatpickr-disabled,
|
||||
.flatpickr-day.prevMonthDay,
|
||||
.flatpickr-day.nextMonthDay { color: var(--text-quaternary); }
|
||||
.flatpickr-calendar .numInputWrapper span:hover { background: var(--bg-secondary); }
|
||||
.terms-subtitle {
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
margin: 16px 0 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.proj-row { display: flex; gap: 6px; align-items: center; }
|
||||
.proj-row select { flex: 1; }
|
||||
.btn-sm { padding: 6px 10px; font-size: 13px; white-space: nowrap; }
|
||||
.proj-panel {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.proj-add { display: flex; gap: 6px; margin-bottom: 10px; }
|
||||
.proj-add input { flex: 1; }
|
||||
.proj-item {
|
||||
display: flex; gap: 6px; align-items: center; margin-bottom: 6px;
|
||||
}
|
||||
.proj-item .proj-kod { width: 130px; font-weight: 600; font-size: 13px; }
|
||||
.proj-item .proj-nazev { flex: 1; }
|
||||
.offer-warn {
|
||||
background: #FEF3C7;
|
||||
border: 1px solid #FCD34D;
|
||||
color: #92400E;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
:root.dark .offer-warn {
|
||||
background: #422006;
|
||||
border-color: #854D0E;
|
||||
color: #FDE68A;
|
||||
}
|
||||
|
||||
.inv-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.inv-card-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary);
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
.inv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
.inv-grid-3 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.inv-grid, .inv-grid-3 { grid-template-columns: 1fr; }
|
||||
}
|
||||
.inv-field { display: flex; flex-direction: column; gap: 4px; }
|
||||
.inv-field-wide { grid-column: 1 / -1; }
|
||||
|
||||
.inv-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.inv-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.inv-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
background-color: var(--bg-primary); /* keep any chevron background-image */
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
/* Unified custom dropdown chevron for both <select> and combobox inputs,
|
||||
so the preset-option fields and the history fields look identical. */
|
||||
select.inv-input,
|
||||
.combo-input {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 9px center;
|
||||
background-size: 15px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
html.dark select.inv-input,
|
||||
html.dark .combo-input {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
select.inv-input::-ms-expand { display: none; }
|
||||
.inv-input.is-empty { color: var(--text-quaternary); font-style: italic; }
|
||||
.inv-input.is-empty::placeholder { color: var(--text-quaternary); }
|
||||
|
||||
/* Tables (line items & vat) */
|
||||
.inv-table-wrap { overflow-x: auto; }
|
||||
.inv-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.inv-table th, .inv-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
text-align: left;
|
||||
}
|
||||
.inv-table th {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
.inv-table td input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.inv-table td input:focus {
|
||||
outline: none;
|
||||
background: var(--bg-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.inv-table td.num input { text-align: right; }
|
||||
.inv-row-actions { width: 36px; text-align: center; }
|
||||
.inv-row-delete {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.inv-row-delete:hover { color: #dc2626; background: rgba(239,68,68,0.08); }
|
||||
|
||||
.inv-table-foot {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.btn-add-row {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--border-strong);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-row:hover { color: var(--primary); border-color: var(--primary); }
|
||||
|
||||
.totals-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 32px;
|
||||
padding-top: 14px;
|
||||
border-top: 2px solid var(--border-default);
|
||||
}
|
||||
.totals-row .total-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
.totals-row .total-cell-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.totals-row .total-cell-input {
|
||||
width: 140px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.totals-row .total-cell.primary .total-cell-input {
|
||||
font-size: 18px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Back link */
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px 6px 10px; border-radius: 8px;
|
||||
font-size: 13px; font-weight: 500; color: var(--text-tertiary);
|
||||
text-decoration: none; border: 0.5px solid var(--border-default);
|
||||
background: var(--bg-primary); flex-shrink: 0;
|
||||
transition: color .15s, border-color .15s, background .15s;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--primary); border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
}
|
||||
.back-link svg { opacity: 0.8; }
|
||||
@media (max-width: 640px) {
|
||||
.back-link span { display: none; }
|
||||
.back-link { padding: 6px; }
|
||||
}
|
||||
137
offer-to-order/static/index.html
Normal file
137
offer-to-order/static/index.html
Normal file
@@ -0,0 +1,137 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="cs">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nabídka → objednávka | Colsys AI</title>
|
||||
<!-- Relative base works both behind the portal (/apps/nabidky/) and
|
||||
standalone at the root (/) on the production host. -->
|
||||
<base href="./">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
|
||||
<link rel="stylesheet" href="static/vendor/flatpickr.min.css?v=10">
|
||||
<link rel="stylesheet" href="static/styles.css?v=10">
|
||||
<link rel="stylesheet" href="static/extra.css?v=10">
|
||||
<script>
|
||||
// Theme bootstrap — ?theme= URL → portal_theme cookie → app_theme localStorage → OS
|
||||
(function () {
|
||||
var t = null;
|
||||
try {
|
||||
var p = new URL(window.location.href).searchParams.get("theme");
|
||||
if (p === "dark" || p === "light") t = p;
|
||||
} catch (e) {}
|
||||
if (!t) {
|
||||
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
|
||||
if (m) t = decodeURIComponent(m[1]);
|
||||
}
|
||||
if (!t) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
|
||||
if (!t && typeof matchMedia !== "undefined") {
|
||||
try { t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } catch (e) {}
|
||||
}
|
||||
if (t === "dark" || t === "light") {
|
||||
document.documentElement.classList.add(t);
|
||||
try { localStorage.setItem("app_theme", t); } catch (e) {}
|
||||
}
|
||||
})();
|
||||
function _resync() {
|
||||
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
|
||||
if (!m) return;
|
||||
var t = decodeURIComponent(m[1]);
|
||||
if (t !== "dark" && t !== "light") return;
|
||||
var cls = document.documentElement.classList;
|
||||
if (cls.contains(t)) return;
|
||||
cls.remove(t === "dark" ? "light" : "dark");
|
||||
cls.add(t);
|
||||
try { localStorage.setItem("app_theme", t); } catch (e) {}
|
||||
}
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "visible") _resync();
|
||||
});
|
||||
window.addEventListener("pageshow", _resync);
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<a href="/" class="brand">
|
||||
<span class="brand-icon">C</span>
|
||||
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
|
||||
</a>
|
||||
<span class="header-crumb">Nabídka → Excel</span>
|
||||
<a href="/" class="back-link" title="Zpět na portál">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
<span>Zpět na portál</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<section id="s-upload">
|
||||
<div class="section-intro">
|
||||
<h1 class="section-title">Převod nabídky do Excelu</h1>
|
||||
<p class="section-desc">
|
||||
Nahrajte dodavatelskou nabídku (PDF nebo Excel). Aplikace z ní
|
||||
vytáhne údaje o dodavateli a položky a připraví Excel v jednotném
|
||||
tvaru. Před stažením můžete data zkontrolovat a upravit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="drop-zone" class="drop-zone">
|
||||
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
<p class="drop-text">Přetáhněte nabídku sem</p>
|
||||
<p class="drop-or">nebo</p>
|
||||
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
|
||||
<p class="drop-formats">PDF · XLSX</p>
|
||||
<input type="file" id="file-input" accept=".pdf,.xlsx,.xlsm,.xls" style="display:none">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="s-processing" class="hidden">
|
||||
<div class="processing-card">
|
||||
<div class="spinner"></div>
|
||||
<h2 class="processing-title">Čtu nabídku…</h2>
|
||||
<p class="processing-sub">AI extrahuje dodavatele a položky. Obvykle 10–40 sekund.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="s-result" class="hidden">
|
||||
<div class="results-header">
|
||||
<div>
|
||||
<h2 class="results-title">Extrahovaná data</h2>
|
||||
<p class="results-meta">Zkontrolujte hodnoty, případně upravte, a stáhněte Excel.</p>
|
||||
</div>
|
||||
<div class="results-actions">
|
||||
<button class="btn btn-secondary" id="restart-btn" type="button">Nová nabídka</button>
|
||||
<button class="btn btn-secondary" id="export-btn" type="button">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
Stáhnout Excel
|
||||
</button>
|
||||
<button class="btn btn-primary" id="order-pdf-btn" type="button">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
|
||||
</svg>
|
||||
Objednávka (PDF)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="offer-form" id="offer-form"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script src="static/vendor/flatpickr.min.js?v=10"></script>
|
||||
<script src="static/vendor/flatpickr.cs.js?v=10"></script>
|
||||
<script src="static/app.js?v=10"></script>
|
||||
</body>
|
||||
</html>
|
||||
461
offer-to-order/static/styles.css
Normal file
461
offer-to-order/static/styles.css
Normal file
@@ -0,0 +1,461 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f9fafb;
|
||||
--bg-tertiary: #f2f4f7;
|
||||
--text-primary: #101828;
|
||||
--text-secondary: #354052;
|
||||
--text-tertiary: #676f83;
|
||||
--text-quaternary: #98a2b2;
|
||||
--border-default: rgb(16 24 40 / 0.08);
|
||||
--border-strong: #d0d5dc;
|
||||
--border-subtle: rgb(16 24 40 / 0.04);
|
||||
--card: #ffffff;
|
||||
--primary: #155aef;
|
||||
--primary-hover: #004aeb;
|
||||
--accent-indigo: #444ce7;
|
||||
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
|
||||
or (b) :root has explicit .dark class (set by portal_theme cookie). */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not(.light) {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
}
|
||||
:root.dark {
|
||||
--bg-primary: #14181f;
|
||||
--bg-secondary: #1a1f29;
|
||||
--bg-tertiary: #232936;
|
||||
--text-primary: #f5f7fa;
|
||||
--text-secondary: #c8ccd5;
|
||||
--text-tertiary: #98a2b2;
|
||||
--text-quaternary: #676f83;
|
||||
--border-default: rgb(255 255 255 / 0.08);
|
||||
--border-strong: #354052;
|
||||
--border-subtle: rgb(255 255 255 / 0.04);
|
||||
--card: #1a1f29;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────── */
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
border-bottom: 0.5px solid var(--border-default);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */
|
||||
margin: 0 auto;
|
||||
height: 56px; /* portal uses h-14 */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px; /* portal px-4 */
|
||||
gap: 16px;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
|
||||
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
letter-spacing: -0.04em;
|
||||
color: white;
|
||||
}
|
||||
.brand-name {
|
||||
font-size: 14px; /* tailwind text-sm */
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em; /* tailwind tracking-tight */
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.brand-ai { color: var(--primary); }
|
||||
.header-crumb {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Main ────────────────────────────────────────── */
|
||||
.main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 24px 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.header-inner { padding: 0 16px; }
|
||||
.main { padding: 24px 16px 60px; }
|
||||
}
|
||||
|
||||
/* ── Section intro ───────────────────────────────── */
|
||||
.section-intro { margin-bottom: 24px; }
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.section-desc {
|
||||
color: var(--text-tertiary);
|
||||
max-width: 580px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Examples panel ──────────────────────────────── */
|
||||
.examples-panel {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.examples-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.examples-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.examples-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.examples-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.examples-list:empty::before {
|
||||
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
font-style: italic;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.example-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
|
||||
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
color: var(--text-primary);
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
.example-chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0;
|
||||
transition: background 0.1s, color 0.1s;
|
||||
}
|
||||
.example-chip-remove:hover {
|
||||
background: color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.example-chip-remove svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.examples-input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.example-input {
|
||||
flex: 1;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-width: 0;
|
||||
}
|
||||
.example-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
.example-add-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Drop zone ───────────────────────────────────── */
|
||||
.drop-zone {
|
||||
border: 1.5px dashed var(--border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--card);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.drag-over {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 4%, var(--card));
|
||||
}
|
||||
.drop-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0 auto 18px;
|
||||
display: block;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.drop-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.drop-or {
|
||||
font-size: 13px;
|
||||
color: var(--text-quaternary);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.drop-formats {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-secondary {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--border-strong); }
|
||||
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
|
||||
|
||||
/* ── Processing card ─────────────────────────────── */
|
||||
.processing-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 56px 32px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid var(--border-strong);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
margin: 0 auto 22px;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.processing-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.steps-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
}
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.step-item.active {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
|
||||
}
|
||||
.step-item.done { color: #17b26a; }
|
||||
.step-item.error { color: #d92d20; background: #fef3f2; }
|
||||
.step-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Results ─────────────────────────────────────── */
|
||||
.results-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.results-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.results-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--card);
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
min-width: 500px;
|
||||
}
|
||||
thead {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
th {
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:nth-child(even) td { background: var(--bg-secondary); }
|
||||
td[contenteditable]:focus {
|
||||
outline: none;
|
||||
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
|
||||
box-shadow: inset 0 0 0 1.5px var(--primary);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-rule { background: #d1e0ff; color: #004aeb; }
|
||||
.badge-llm { background: #d1fae5; color: #065f46; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.badge-rule { background: #1e3a8a; color: #93c5fd; }
|
||||
.badge-llm { background: #064e3b; color: #6ee7b7; }
|
||||
}
|
||||
|
||||
.table-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
75
offer-to-order/static/vendor/flatpickr.cs.js
vendored
Normal file
75
offer-to-order/static/vendor/flatpickr.cs.js
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.cs = {}));
|
||||
}(this, (function (exports) { 'use strict';
|
||||
|
||||
var fp = typeof window !== "undefined" && window.flatpickr !== undefined
|
||||
? window.flatpickr
|
||||
: {
|
||||
l10ns: {},
|
||||
};
|
||||
var Czech = {
|
||||
weekdays: {
|
||||
shorthand: ["Ne", "Po", "Út", "St", "Čt", "Pá", "So"],
|
||||
longhand: [
|
||||
"Neděle",
|
||||
"Pondělí",
|
||||
"Úterý",
|
||||
"Středa",
|
||||
"Čtvrtek",
|
||||
"Pátek",
|
||||
"Sobota",
|
||||
],
|
||||
},
|
||||
months: {
|
||||
shorthand: [
|
||||
"Led",
|
||||
"Ún",
|
||||
"Bře",
|
||||
"Dub",
|
||||
"Kvě",
|
||||
"Čer",
|
||||
"Čvc",
|
||||
"Srp",
|
||||
"Zář",
|
||||
"Říj",
|
||||
"Lis",
|
||||
"Pro",
|
||||
],
|
||||
longhand: [
|
||||
"Leden",
|
||||
"Únor",
|
||||
"Březen",
|
||||
"Duben",
|
||||
"Květen",
|
||||
"Červen",
|
||||
"Červenec",
|
||||
"Srpen",
|
||||
"Září",
|
||||
"Říjen",
|
||||
"Listopad",
|
||||
"Prosinec",
|
||||
],
|
||||
},
|
||||
firstDayOfWeek: 1,
|
||||
ordinal: function () {
|
||||
return ".";
|
||||
},
|
||||
rangeSeparator: " do ",
|
||||
weekAbbreviation: "Týd.",
|
||||
scrollTitle: "Rolujte pro změnu",
|
||||
toggleTitle: "Přepnout dopoledne/odpoledne",
|
||||
amPM: ["dop.", "odp."],
|
||||
yearAriaLabel: "Rok",
|
||||
time_24hr: true,
|
||||
};
|
||||
fp.l10ns.cs = Czech;
|
||||
var cs = fp.l10ns;
|
||||
|
||||
exports.Czech = Czech;
|
||||
exports.default = cs;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
13
offer-to-order/static/vendor/flatpickr.min.css
vendored
Normal file
13
offer-to-order/static/vendor/flatpickr.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
2
offer-to-order/static/vendor/flatpickr.min.js
vendored
Normal file
2
offer-to-order/static/vendor/flatpickr.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
181
offer-to-order/store.py
Normal file
181
offer-to-order/store.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""Persistent state for the order form (one JSON file in WORK_DIR volume).
|
||||
|
||||
Per project (keyed by Kód zakázky) we keep:
|
||||
- nazev: Název zakázky
|
||||
- last: {field: last-used value} → used to prefill
|
||||
- hist: {field: {value: count}} → all values ever used, with counts
|
||||
(so the UI can offer them, most-used
|
||||
on top)
|
||||
|
||||
Plus global meta: last order number (for the +1 suggestion).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
_STORE = Path(os.getenv("WORK_DIR", "/tmp/offer-to-order")) / "order_state.json"
|
||||
_lock = threading.Lock()
|
||||
|
||||
# Fields remembered per project (Název is stored separately as "nazev").
|
||||
STICKY = [
|
||||
"misto_dodani", "kontaktni_osoba", "vystavil",
|
||||
"splatnost", "zaruka", "zpusob_platby", "zpusob_odberu", "zpusob_dopravy",
|
||||
"forma_objednavky", "fakturujte", "popis",
|
||||
]
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
try:
|
||||
d = json.loads(_STORE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
d = {}
|
||||
d.setdefault("projects", {})
|
||||
d.setdefault("meta", {})
|
||||
return d
|
||||
|
||||
|
||||
def _save(d: dict) -> None:
|
||||
_STORE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_STORE.write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _normalize(rec: dict) -> dict:
|
||||
"""Upgrade an old flat-style record to the {nazev,last,hist} shape."""
|
||||
if "last" in rec or "hist" in rec:
|
||||
rec.setdefault("last", {})
|
||||
rec.setdefault("hist", {})
|
||||
return rec
|
||||
nazev = rec.get("nazev", "")
|
||||
last, hist = {}, {}
|
||||
for k in STICKY:
|
||||
if rec.get(k) not in (None, ""):
|
||||
last[k] = rec[k]
|
||||
hist[k] = {rec[k]: 1}
|
||||
return {"nazev": nazev, "last": last, "hist": hist}
|
||||
|
||||
|
||||
# ── Projects ──
|
||||
def list_projects() -> list[dict]:
|
||||
projects = _load()["projects"]
|
||||
return sorted(
|
||||
[{"kod": k, "nazev": _normalize(v).get("nazev", "")}
|
||||
for k, v in projects.items()],
|
||||
key=lambda p: p["kod"].lower(),
|
||||
)
|
||||
|
||||
|
||||
def get_defaults(code: str) -> dict:
|
||||
"""Prefill values (last used) + a _history map ranked most-used first."""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return {}
|
||||
raw = _load()["projects"].get(code)
|
||||
if not raw:
|
||||
return {}
|
||||
rec = _normalize(raw)
|
||||
out = dict(rec["last"])
|
||||
if rec.get("nazev"):
|
||||
out["nazev_zakazky"] = rec["nazev"]
|
||||
history = {}
|
||||
for field, counts in rec.get("hist", {}).items():
|
||||
ranked = sorted(counts.items(), key=lambda kv: -kv[1])
|
||||
history[field] = [{"value": v, "count": c} for v, c in ranked]
|
||||
out["_history"] = history
|
||||
return out
|
||||
|
||||
|
||||
def upsert_project(code: str, nazev: str) -> None:
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return
|
||||
with _lock:
|
||||
d = _load()
|
||||
rec = _normalize(d["projects"].get(code, {}))
|
||||
rec["nazev"] = (nazev or "").strip()
|
||||
d["projects"][code] = rec
|
||||
_save(d)
|
||||
|
||||
|
||||
def delete_project(code: str) -> None:
|
||||
code = (code or "").strip()
|
||||
with _lock:
|
||||
d = _load()
|
||||
if code in d["projects"]:
|
||||
del d["projects"][code]
|
||||
_save(d)
|
||||
|
||||
|
||||
def delete_history_value(code: str, field: str, value: str) -> None:
|
||||
"""Remove one remembered value from a project field's history."""
|
||||
code = (code or "").strip()
|
||||
if not code or not field:
|
||||
return
|
||||
with _lock:
|
||||
d = _load()
|
||||
raw = d["projects"].get(code)
|
||||
if not raw:
|
||||
return
|
||||
rec = _normalize(raw)
|
||||
counts = rec["hist"].get(field)
|
||||
if counts and value in counts:
|
||||
del counts[value]
|
||||
if not counts:
|
||||
rec["hist"].pop(field, None)
|
||||
if rec["last"].get(field) == value:
|
||||
remaining = rec["hist"].get(field)
|
||||
if remaining:
|
||||
rec["last"][field] = max(remaining, key=remaining.get)
|
||||
else:
|
||||
rec["last"].pop(field, None)
|
||||
d["projects"][code] = rec
|
||||
_save(d)
|
||||
|
||||
|
||||
def save_defaults(code: str, order: dict) -> None:
|
||||
"""Record sticky-field values for a project: update 'last' and bump the
|
||||
frequency count in 'hist' for each non-empty value."""
|
||||
code = (code or "").strip()
|
||||
if not code:
|
||||
return
|
||||
with _lock:
|
||||
d = _load()
|
||||
rec = _normalize(d["projects"].get(code, {}))
|
||||
if order.get("nazev_zakazky"):
|
||||
rec["nazev"] = order["nazev_zakazky"]
|
||||
for k in STICKY:
|
||||
v = order.get(k)
|
||||
if v in (None, ""):
|
||||
continue
|
||||
v = str(v)
|
||||
rec["last"][k] = v
|
||||
rec["hist"].setdefault(k, {})
|
||||
rec["hist"][k][v] = rec["hist"][k].get(v, 0) + 1
|
||||
d["projects"][code] = rec
|
||||
_save(d)
|
||||
|
||||
|
||||
# ── Order-number counter ──
|
||||
def next_order_number() -> str:
|
||||
return _increment(_load()["meta"].get("last_order_number") or "")
|
||||
|
||||
|
||||
def _increment(s: str) -> str:
|
||||
if not s:
|
||||
return ""
|
||||
m = re.search(r"(\d+)(\D*)$", s)
|
||||
if not m:
|
||||
return s
|
||||
num = m.group(1)
|
||||
return s[: m.start(1)] + str(int(num) + 1).zfill(len(num)) + m.group(2)
|
||||
|
||||
|
||||
def remember_order_number(num: str) -> None:
|
||||
num = (num or "").strip()
|
||||
if not num:
|
||||
return
|
||||
with _lock:
|
||||
d = _load()
|
||||
d["meta"]["last_order_number"] = num
|
||||
_save(d)
|
||||
Reference in New Issue
Block a user