Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
LITELLM_BASE_URL=http://host.docker.internal:4000/v1
LITELLM_API_KEY=sk-...
LLM_MODEL=anthropic/claude-sonnet-4-20250514

View File

@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /tmp/invoice-extractor
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,24 @@
services:
invoice-extractor:
build: .
container_name: invoice-extractor
restart: unless-stopped
ports:
- "127.0.0.1:3029: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}
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- localai
volumes:
- invoice-extractor-data:/tmp/invoice-extractor
volumes:
invoice-extractor-data:
networks:
localai:
external: true

View File

@@ -0,0 +1,154 @@
"""Render extracted invoice data into a tidy XLSX file."""
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)
def write_invoice_xlsx(data: dict, out_path: str) -> None:
wb = Workbook()
ws = wb.active
ws.title = "Faktura"
# ── Header section ──
ws.cell(row=1, column=1, value="FAKTURA — extrahovaná data").font = Font(bold=True, size=14)
ws.merge_cells("A1:D1")
row = 3
row = _write_kv_block(ws, row, "Identifikace faktury", [
("Číslo faktury", data.get("invoice_number")),
("Variabilní symbol", data.get("variable_symbol")),
("Konstantní symbol", data.get("constant_symbol")),
("Specifický symbol", data.get("specific_symbol")),
("Datum vystavení", data.get("issue_date")),
("Datum splatnosti", data.get("due_date")),
("DUZP", data.get("taxable_date")),
("Měna", data.get("currency") or "CZK"),
("Způsob platby", data.get("payment_method")),
("Číslo účtu", data.get("bank_account")),
("IBAN", data.get("iban")),
])
row += 1
sup = data.get("supplier") or {}
row = _write_kv_block(ws, row, "Dodavatel", [
("Název", sup.get("name")),
("IČO", sup.get("ico")),
("DIČ", sup.get("dic")),
("Adresa", sup.get("address")),
])
row += 1
cust = data.get("customer") or {}
row = _write_kv_block(ws, row, "Odběratel", [
("Název", cust.get("name")),
("IČO", cust.get("ico")),
("DIČ", cust.get("dic")),
("Adresa", cust.get("address")),
])
row += 1
# ── Line items table ──
items = data.get("line_items") or []
if items:
row = _write_section_header(ws, row, "Položky faktury")
headers = ["Popis", "Množství", "Jednotka", "Cena/jed. bez DPH",
"Sazba DPH (%)", "Bez DPH", "S DPH"]
for c, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=c, value=h)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center")
cell.border = BORDER
row += 1
for item in items:
values = [
item.get("description") or "",
item.get("quantity"),
item.get("unit") or "",
item.get("unit_price_excluding_vat"),
item.get("vat_rate"),
item.get("total_excluding_vat"),
item.get("total_including_vat"),
]
for c, v in enumerate(values, 1):
cell = ws.cell(row=row, column=c, value=v)
cell.border = BORDER
if c >= 4 and isinstance(v, (int, float)):
cell.number_format = "#,##0.00"
row += 1
row += 1
# ── VAT breakdown ──
vat = data.get("vat_breakdown") or []
if vat:
row = _write_section_header(ws, row, "Rekapitulace DPH")
headers = ["Sazba (%)", "Základ", "DPH", "Celkem"]
for c, h in enumerate(headers, 1):
cell = ws.cell(row=row, column=c, value=h)
cell.font = HEADER_FONT
cell.fill = HEADER_FILL
cell.alignment = Alignment(horizontal="center")
cell.border = BORDER
row += 1
for br in vat:
for c, v in enumerate([br.get("rate"), br.get("base"),
br.get("vat"), br.get("total")], 1):
cell = ws.cell(row=row, column=c, value=v)
cell.border = BORDER
if c >= 2 and isinstance(v, (int, float)):
cell.number_format = "#,##0.00"
row += 1
row += 1
# ── Totals ──
row = _write_section_header(ws, row, "Celkem")
totals = [
("Celkem bez DPH", data.get("total_excluding_vat")),
("Celkem DPH", data.get("total_vat")),
("CELKEM K ÚHRADĚ", data.get("total_including_vat")),
]
for label, val in totals:
ws.cell(row=row, column=1, value=label).font = LABEL_FONT
cell = ws.cell(row=row, column=2, value=val)
if isinstance(val, (int, float)):
cell.number_format = "#,##0.00"
if label.startswith("CELKEM K"):
ws.cell(row=row, column=1).font = Font(bold=True, size=12)
cell.font = Font(bold=True, size=12)
row += 1
if data.get("notes"):
row += 1
ws.cell(row=row, column=1, value="Poznámka:").font = LABEL_FONT
ws.cell(row=row, column=2, value=data["notes"])
# ── Auto-widths ──
widths = {1: 32, 2: 14, 3: 10, 4: 16, 5: 14, 6: 14, 7: 14}
for c, w in widths.items():
ws.column_dimensions[get_column_letter(c)].width = w
wb.save(out_path)
def _write_section_header(ws, row: int, text: str) -> int:
cell = ws.cell(row=row, column=1, value=text)
cell.font = Font(bold=True, size=12, color="0F1729")
cell.fill = PatternFill("solid", fgColor="EFF4FF")
ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=7)
return row + 1
def _write_kv_block(ws, row: int, header: str, pairs: list[tuple]) -> int:
row = _write_section_header(ws, row, header)
for label, val in 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
return row

View File

@@ -0,0 +1,190 @@
"""Invoice data extraction via Claude vision."""
import base64
import io
import json
import logging
import os
from pathlib import Path
import fitz # PyMuPDF
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 = 6 # cap to keep token cost predictable
MAX_IMG_LONG_EDGE = 2200 # px — enough resolution for invoice details
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 faktur. Z obrázku/obrázků faktury vyextrahujete strukturovaná data pro účetní systém.
Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly, žádný komentář mimo JSON:
{
"invoice_number": "string|null",
"issue_date": "YYYY-MM-DD|null",
"due_date": "YYYY-MM-DD|null",
"taxable_date": "YYYY-MM-DD|null",
"variable_symbol": "string|null",
"constant_symbol": "string|null",
"specific_symbol": "string|null",
"currency": "CZK",
"supplier": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"address": "string|null"
},
"customer": {
"name": "string|null",
"ico": "string|null",
"dic": "string|null",
"address": "string|null"
},
"bank_account": "string|null",
"iban": "string|null",
"payment_method": "převod|hotově|karta|dobírka|jiné|null",
"line_items": [
{
"description": "string",
"quantity": "number|null",
"unit": "string|null",
"unit_price_excluding_vat": "number|null",
"vat_rate": "number|null",
"total_excluding_vat": "number|null",
"total_including_vat": "number|null"
}
],
"vat_breakdown": [
{"rate": 21, "base": 0.0, "vat": 0.0, "total": 0.0}
],
"total_excluding_vat": "number|null",
"total_vat": "number|null",
"total_including_vat": "number|null",
"notes": "string|null"
}
Pravidla:
- Datumy zapisujte ve formátu YYYY-MM-DD (ISO 8601).
- Čísla zapisujte jako desetinná čísla s tečkou jako oddělovačem (např. 1234.56). NIKDY nepoužívejte čárku ani mezery v číslech.
- Pokud údaj na faktuře není, použijte null. NEVYMÝŠLEJTE si.
- IČO je 8místné číslo (může mít vedoucí nuly). DIČ obvykle začíná „CZ".
- variable_symbol je obvykle stejný jako číslo faktury, ale ne vždy — zapisujte přesně co je na faktuře.
- U položek (line_items) zachovejte přesné pořadí jak jsou na faktuře.
- Pokud je faktura v jiné měně než CZK, zapište správný kód měny (EUR, USD, atd.).
- Adresy zapisujte jako jeden řetězec (ulice + číslo, PSČ město)."""
async def extract_invoice(pdf_or_image_path: Path) -> dict:
"""Extract structured invoice data using Claude vision."""
images = _to_images(pdf_or_image_path)
if not images:
raise RuntimeError("Soubor neobsahuje žádné zobrazitelné stránky")
content = [
{"type": "text",
"text": "Z následujících obrázků faktury vyextrahujte strukturovaná data podle definovaného JSON tvaru."},
]
for img_b64 in images:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_b64}"},
})
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=4000,
)
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)
def _to_images(path: Path) -> list[str]:
"""Return list of base64-encoded PNG strings, one per page."""
suffix = path.suffix.lower()
if suffix == ".pdf":
doc = fitz.open(str(path))
out = []
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)
out.append(_compress(img))
doc.close()
return out
elif suffix in (".jpg", ".jpeg", ".png", ".webp"):
img = Image.open(path).convert("RGB")
return [_compress(img)]
raise RuntimeError(f"Nepodporovaný formát: {suffix}")
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:
"""Coerce known numeric fields to float and clean nulls."""
def _num(v):
if v is None or v == "":
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).replace(",", ".").replace(" ", ""))
except (ValueError, TypeError):
return None
for k in ("total_excluding_vat", "total_vat", "total_including_vat"):
if k in data:
data[k] = _num(data[k])
for item in data.get("line_items") or []:
for k in ("quantity", "unit_price_excluding_vat", "vat_rate",
"total_excluding_vat", "total_including_vat"):
if k in item:
item[k] = _num(item[k])
for br in data.get("vat_breakdown") or []:
for k in ("rate", "base", "vat", "total"):
if k in br:
br[k] = _num(br[k])
return data

98
invoice-extractor/main.py Normal file
View File

@@ -0,0 +1,98 @@
"""FastAPI: invoice PDF/image → structured data → editable form → XLSX export."""
import asyncio
import logging
import os
import uuid
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from excel_export import write_invoice_xlsx
from extractor import extract_invoice
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Invoice Extractor")
app.add_middleware(CORSMiddleware, allow_origins=["*"],
allow_methods=["*"], allow_headers=["*"])
WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/invoice-extractor"))
WORK_DIR.mkdir(parents=True, exist_ok=True)
jobs: dict[str, dict] = {}
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp"}
@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_invoice(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"]) / "invoice.xlsx"
write_invoice_xlsx(job["data"], str(out_path))
inv_no = (job["data"].get("invoice_number") or
Path(job["filename"]).stem if job.get("filename") else "faktura")
safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(inv_no))
return FileResponse(
str(out_path),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
filename=f"faktura_{safe}.xlsx",
)
@app.get("/health")
async def health():
return {"status": "ok"}
app.mount("/static", StaticFiles(directory="static"), name="static")

View File

@@ -0,0 +1,8 @@
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

View File

@@ -0,0 +1,352 @@
// Invoice extractor 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]));
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 || {};
renderForm();
show("result");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
// ── Render editable form ──
function field(key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : 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-key="${key}" type="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function nestedField(parent, key, label, value, opts = {}) {
const isEmpty = value === null || value === undefined || value === "";
const display = isEmpty ? "" : 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="${opts.type || "text"}"
value="${escapeHtmlAttr(display)}"
placeholder="${escapeHtmlAttr(opts.placeholder || "—")}">
</div>`;
}
function renderForm() {
const d = state.data;
const sup = d.supplier || {};
const cust = d.customer || {};
const items = d.line_items || [];
const vat = d.vat_breakdown || [];
const html = `
<!-- Identifikace -->
<div class="inv-card">
<div class="inv-card-title">Identifikace faktury</div>
<div class="inv-grid-3">
${field("invoice_number", "Číslo faktury", d.invoice_number)}
${field("variable_symbol", "Variabilní symbol", d.variable_symbol)}
${field("currency", "Měna", d.currency || "CZK")}
${field("issue_date", "Datum vystavení", d.issue_date, {type: "date"})}
${field("due_date", "Datum splatnosti", d.due_date, {type: "date"})}
${field("taxable_date", "DUZP", d.taxable_date, {type: "date"})}
${field("constant_symbol", "Konstantní symbol", d.constant_symbol)}
${field("specific_symbol", "Specifický symbol", d.specific_symbol)}
${field("payment_method", "Způsob platby", d.payment_method)}
</div>
</div>
<!-- Dodavatel -->
<div class="inv-card">
<div class="inv-card-title">Dodavatel</div>
<div class="inv-grid">
${nestedField("supplier", "name", "Název", sup.name, {wide: true})}
${nestedField("supplier", "ico", "IČO", sup.ico)}
${nestedField("supplier", "dic", "DIČ", sup.dic)}
${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})}
</div>
</div>
<!-- Odběratel -->
<div class="inv-card">
<div class="inv-card-title">Odběratel</div>
<div class="inv-grid">
${nestedField("customer", "name", "Název", cust.name, {wide: true})}
${nestedField("customer", "ico", "IČO", cust.ico)}
${nestedField("customer", "dic", "DIČ", cust.dic)}
${nestedField("customer", "address", "Adresa", cust.address, {wide: true})}
</div>
</div>
<!-- Platba -->
<div class="inv-card">
<div class="inv-card-title">Platební údaje</div>
<div class="inv-grid">
${field("bank_account", "Číslo účtu", d.bank_account)}
${field("iban", "IBAN", d.iban)}
</div>
</div>
<!-- Položky -->
<div class="inv-card">
<div class="inv-card-title">Položky faktury</div>
<div class="inv-table-wrap">
<table class="inv-table" id="items-table">
<thead>
<tr>
<th style="min-width:200px">Popis</th>
<th style="width:80px">Množství</th>
<th style="width:70px">Jednotka</th>
<th style="width:110px">Cena/jed. bez DPH</th>
<th style="width:70px">DPH %</th>
<th style="width:110px">Bez DPH</th>
<th style="width:110px">S DPH</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>
${vat.length ? `
<!-- Rekapitulace DPH -->
<div class="inv-card">
<div class="inv-card-title">Rekapitulace DPH</div>
<div class="inv-table-wrap">
<table class="inv-table" id="vat-table">
<thead>
<tr>
<th style="width:100px">Sazba (%)</th>
<th>Základ</th>
<th>DPH</th>
<th>Celkem</th>
<th class="inv-row-actions"></th>
</tr>
</thead>
<tbody id="vat-body">
${vat.map((br, i) => renderVatRow(br, i)).join("")}
</tbody>
</table>
</div>
</div>` : ""}
<!-- Totals -->
<div class="inv-card">
<div class="inv-card-title">Celkem</div>
<div class="totals-row">
<div class="total-cell">
<span class="total-cell-label">Bez DPH</span>
<input class="inv-input total-cell-input" data-key="total_excluding_vat"
type="text" value="${fmtNum(d.total_excluding_vat)}">
</div>
<div class="total-cell">
<span class="total-cell-label">DPH</span>
<input class="inv-input total-cell-input" data-key="total_vat"
type="text" value="${fmtNum(d.total_vat)}">
</div>
<div class="total-cell primary">
<span class="total-cell-label">K úhradě</span>
<input class="inv-input total-cell-input" data-key="total_including_vat"
type="text" value="${fmtNum(d.total_including_vat)}">
</div>
</div>
</div>
${d.notes ? `
<div class="inv-card">
<div class="inv-card-title">Poznámka</div>
<input class="inv-input" data-key="notes" type="text"
value="${escapeHtmlAttr(d.notes)}">
</div>` : ""}
`;
$("invoice-form").innerHTML = html;
bindFieldHandlers();
}
function renderItemRow(item, i) {
return `
<tr data-row="${i}">
<td><input data-field="description" type="text" value="${escapeHtmlAttr(item.description || "")}"></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_excluding_vat" type="text" value="${fmtNum(item.unit_price_excluding_vat)}"></td>
<td class="num"><input data-field="vat_rate" type="text" value="${fmtNum(item.vat_rate)}"></td>
<td class="num"><input data-field="total_excluding_vat" type="text" value="${fmtNum(item.total_excluding_vat)}"></td>
<td class="num"><input data-field="total_including_vat" type="text" value="${fmtNum(item.total_including_vat)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-item" title="Smazat">×</button></td>
</tr>`;
}
function renderVatRow(br, i) {
return `
<tr data-row="${i}">
<td class="num"><input data-field="rate" type="text" value="${fmtNum(br.rate)}"></td>
<td class="num"><input data-field="base" type="text" value="${fmtNum(br.base)}"></td>
<td class="num"><input data-field="vat" type="text" value="${fmtNum(br.vat)}"></td>
<td class="num"><input data-field="total" type="text" value="${fmtNum(br.total)}"></td>
<td class="inv-row-actions"><button class="inv-row-delete" type="button" data-action="delete-vat" title="Smazat">×</button></td>
</tr>`;
}
function bindFieldHandlers() {
// Top-level + nested fields
$("invoice-form").querySelectorAll("input[data-key]").forEach((el) => {
el.addEventListener("input", () => {
const key = el.dataset.key;
const parent = el.dataset.parent;
const val = parseFieldValue(key, el.value);
if (parent) {
if (!state.data[parent]) state.data[parent] = {};
state.data[parent][key] = val;
} else {
state.data[key] = val;
}
el.classList.toggle("is-empty", el.value === "");
});
});
// Item rows
const itemsBody = $("items-body");
if (itemsBody) {
itemsBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +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_excluding_vat", "vat_rate",
"total_excluding_vat", "total_including_vat"];
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 tr = btn.closest("tr");
const i = +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({description: "", quantity: null, unit: "",
unit_price_excluding_vat: null, vat_rate: null,
total_excluding_vat: null, total_including_vat: null});
renderForm();
});
}
// VAT rows
const vatBody = $("vat-body");
if (vatBody) {
vatBody.addEventListener("input", (e) => {
const inp = e.target.closest("input");
if (!inp) return;
const tr = inp.closest("tr");
const i = +tr.dataset.row;
const field = inp.dataset.field;
if (!state.data.vat_breakdown) state.data.vat_breakdown = [];
if (!state.data.vat_breakdown[i]) state.data.vat_breakdown[i] = {};
state.data.vat_breakdown[i][field] = parseNum(inp.value);
});
vatBody.addEventListener("click", (e) => {
const btn = e.target.closest('[data-action="delete-vat"]');
if (!btn) return;
const tr = btn.closest("tr");
const i = +tr.dataset.row;
state.data.vat_breakdown.splice(i, 1);
renderForm();
});
}
}
function parseFieldValue(key, str) {
// Numeric top-level fields
const num = ["total_excluding_vat", "total_vat", "total_including_vat"];
if (num.includes(key)) return parseNum(str);
return str;
}
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 "";
if (typeof v === "number") return String(v);
return String(v);
}
// ── Export ──
$("export-btn").addEventListener("click", async () => {
if (!state.jobId) return;
// Persist edits server-side before downloading
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 = "";
$("invoice-form").innerHTML = "";
show("upload");
});
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function escapeHtmlAttr(s) { return escapeHtml(s); }
})();

View File

@@ -0,0 +1,186 @@
/* Invoice-extractor specific styles */
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin: 8px auto 0;
max-width: 400px;
}
.invoice-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.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: var(--bg-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.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; }
}

View File

@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Extrakce faktur | Colsys AI</title>
<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/styles.css">
<link rel="stylesheet" href="/static/extra.css">
<script>
(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) { try { t = localStorage.getItem("app_theme"); } catch (e) {} }
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" 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">Extrakce faktur</span>
<a href="https://ai.klas.chat" 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">Extrakce dat z faktury</h1>
<p class="section-desc">
Nahrajte fakturu (PDF nebo scan) a získáte strukturovaná data
připravená pro účetní systém. Po extrakci můžete data ručně upravit
a stáhnout jako Excel.
</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 fakturu 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 · JPG · PNG · WEBP</p>
<input type="file" id="file-input" accept=".pdf,.jpg,.jpeg,.png,.webp" style="display:none">
</div>
</section>
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title">Čtu fakturu…</h2>
<p class="processing-sub">AI extrahuje data z obrázku. Obvykle 1030 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á faktura</button>
<button class="btn btn-primary" 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>
</div>
</div>
<div class="invoice-form" id="invoice-form"></div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View 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; }