Našeptávače

master
Kowalski 2026-10-31 11:03:37 +01:00
parent 6a39a66e73
commit 302edd094b
5 changed files with 474 additions and 13 deletions

View File

@ -124,6 +124,49 @@ class FakturaFacade
", $fakturaId)->fetchAll(); ", $fakturaId)->fetchAll();
} }
/**
* Názvy položek pro našeptávač. Jednotka a cena jsou z posledního použití,
* pořadí je podle počtu výskytů na fakturách za poslední rok.
*
* @return list<array{nazev: string, jednotka: string, cena: float}>
*/
public function getKatalogPolozek(): array
{
$od = (new \DateTimeImmutable('today'))->modify('-1 year')->format('Y-m-d');
$rows = $this->kufrikExplorer->query("
SELECT katalog.NAZEV, katalog.JEDNOTKA, katalog.CENA_JEDNOTKA
FROM (
SELECT
P.NAZEV,
P.JEDNOTKA,
P.CENA_JEDNOTKA,
ROW_NUMBER() OVER (PARTITION BY P.NAZEV ORDER BY P.ID DESC) AS rn
FROM dbo.POLOZKA P
WHERE P.NAZEV IS NOT NULL AND LTRIM(RTRIM(P.NAZEV)) <> ''
) AS katalog
LEFT JOIN (
SELECT P.NAZEV, COUNT(*) AS POCET
FROM dbo.POLOZKA P
INNER JOIN dbo.FAKTURA F ON F.ID = P.FAKTURA
WHERE F.DATUM_VYSTAVENI >= ?
GROUP BY P.NAZEV
) AS pouziti ON pouziti.NAZEV = katalog.NAZEV
WHERE katalog.rn = 1
ORDER BY ISNULL(pouziti.POCET, 0) DESC, katalog.NAZEV ASC
", $od)->fetchAll();
$katalog = [];
foreach ($rows as $row) {
$katalog[] = [
'nazev' => (string) $row->NAZEV,
'jednotka' => trim((string) ($row->JEDNOTKA ?? '')),
'cena' => round((float) $row->CENA_JEDNOTKA, 2),
];
}
return $katalog;
}
/** /**
* Maska, období číslování a výchozí splatnost uložené u firmy. * Maska, období číslování a výchozí splatnost uložené u firmy.
* *

View File

@ -25,7 +25,7 @@
</script> </script>
{* naše vlastní styly *} {* naše vlastní styly *}
<link href="{$basePath}/css/layout.css?v={$appVersion ?? '1.0'}-6" rel="stylesheet"> <link href="{$basePath}/css/layout.css?v={$appVersion ?? '1.0'}-7" rel="stylesheet">
</head> </head>
<body> <body>

View File

@ -109,6 +109,10 @@ class FakturaEditPresenter extends BasePresenter
} }
} }
$this->template->katalogPolozekJson = json_encode(
$this->fakturaFacade->getKatalogPolozek(),
JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR
);
$this->template->polozky = $polozky; $this->template->polozky = $polozky;
$this->template->platby = $platby; $this->template->platby = $platby;
$this->template->celkemFaktury = $celkem; $this->template->celkemFaktury = $celkem;
@ -135,16 +139,28 @@ class FakturaEditPresenter extends BasePresenter
{ {
$form = new Form; $form = new Form;
// 1. Načteme všechny zákazníky (parametr 'vse' projde bez filtru AKTIVNI) $zakaznici = $this->zakaznikFacade->getFiltered('', 'aktivni');
$zakaznici = $this->zakaznikFacade->getFiltered('', 'vse'); if ($this->id) {
$faktura = $this->fakturaFacade->getById($this->id);
$zakaznikId = $faktura ? (int) $faktura->ZAKAZNIK : 0;
$uzJeVSeznamu = false;
foreach ($zakaznici as $z) {
if ((int) $z->ID === $zakaznikId) {
$uzJeVSeznamu = true;
break;
}
}
if ($zakaznikId && !$uzJeVSeznamu) {
$stavajici = $this->zakaznikFacade->getById($zakaznikId);
if ($stavajici) {
$zakaznici[] = $stavajici;
}
}
}
$zakazniciPairs = []; $zakazniciPairs = [];
foreach ($zakaznici as $z) { foreach ($zakaznici as $z) {
$nazev = trim(($z->PRIJMENI ?? '') . ' ' . ($z->JMENO ?? '')); $zakazniciPairs[(int) $z->ID] = $this->nazevZakaznika($z);
if (!empty($z->IC)) {
$nazev .= " (IČ: {$z->IC})";
}
// Kličem musí být int
$zakazniciPairs[(int) $z->ID] = $nazev;
} }
$form->addSelect('ZAKAZNIK_ID', 'Odběratel:', $zakazniciPairs) $form->addSelect('ZAKAZNIK_ID', 'Odběratel:', $zakazniciPairs)
@ -184,6 +200,23 @@ class FakturaEditPresenter extends BasePresenter
return $form; return $form;
} }
private function nazevZakaznika(object $zakaznik): string
{
$nazev = trim(($zakaznik->PRIJMENI ?? '') . ' ' . ($zakaznik->JMENO ?? ''));
$doplnek = [];
if (!empty($zakaznik->MESTO)) {
$doplnek[] = (string) $zakaznik->MESTO;
}
if (!empty($zakaznik->IC)) {
$doplnek[] = 'IČ: ' . $zakaznik->IC;
}
if ($doplnek) {
$nazev .= ' (' . implode(', ', $doplnek) . ')';
}
return $nazev;
}
public function fakturaFormSucceeded(Form $form, \stdClass $data): void public function fakturaFormSucceeded(Form $form, \stdClass $data): void
{ {
$firmaId = $this->getUser()->getIdentity()->firma_id; $firmaId = $this->getUser()->getIdentity()->firma_id;

View File

@ -27,7 +27,7 @@
<!-- Hlavička Faktury --> <!-- Hlavička Faktury -->
<div class="col-md-6"> <div class="col-md-6">
<label n:name="ZAKAZNIK_ID" class="form-label fw-bold">Odběratel *</label> <label n:name="ZAKAZNIK_ID" class="form-label fw-bold">Odběratel *</label>
<select n:name="ZAKAZNIK_ID" class="form-select"></select> <select n:name="ZAKAZNIK_ID" class="form-select" data-suggest="zakaznik" {if $isEdit}data-locked="1"{/if}></select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label n:name="CISLO" class="form-label fw-bold">Číslo faktury *</label> <label n:name="CISLO" class="form-label fw-bold">Číslo faktury *</label>
@ -79,7 +79,7 @@
<td data-label="Pořadí" class="text-center align-middle drag-handle" style="cursor: grab;"> <td data-label="Pořadí" class="text-center align-middle drag-handle" style="cursor: grab;">
<i class="bi bi-grip-vertical text-muted fs-5"></i> <i class="bi bi-grip-vertical text-muted fs-5"></i>
</td> </td>
<td data-label="Název"><input type="text" name="polozky[{$index}][NAZEV]" value="{$p->NAZEV}" class="form-control item-nazev" required></td> <td data-label="Název"><input type="text" name="polozky[{$index}][NAZEV]" value="{$p->NAZEV}" class="form-control item-nazev" placeholder="Začněte psát název práce nebo zboží" required autocomplete="off"></td>
<td data-label="Počet"><input type="number" step="0.01" name="polozky[{$index}][POCET]" value="{$p->POCET}" class="form-control item-pocet text-end" required></td> <td data-label="Počet"><input type="number" step="0.01" name="polozky[{$index}][POCET]" value="{$p->POCET}" class="form-control item-pocet text-end" required></td>
<td data-label="Jedn."><input type="text" name="polozky[{$index}][JEDNOTKA]" value="{$p->JEDNOTKA}" class="form-control"></td> <td data-label="Jedn."><input type="text" name="polozky[{$index}][JEDNOTKA]" value="{$p->JEDNOTKA}" class="form-control"></td>
<td data-label="Cena/Jedn."><input type="number" step="0.01" name="polozky[{$index}][CENA_JEDNOTKA]" value="{$p->CENA_JEDNOTKA}" class="form-control item-cena text-end" required></td> <td data-label="Cena/Jedn."><input type="number" step="0.01" name="polozky[{$index}][CENA_JEDNOTKA]" value="{$p->CENA_JEDNOTKA}" class="form-control item-cena text-end" required></td>
@ -209,6 +209,8 @@ document.getElementById('edit-platba-splatnost').addEventListener('click', funct
}); });
</script> </script>
<script type="application/json" id="katalog-polozek">{$katalogPolozekJson|noescape}</script>
{syntax off} {syntax off}
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -260,6 +262,328 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('total-sum').textContent = grandTotal.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' CZK'; document.getElementById('total-sum').textContent = grandTotal.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' CZK';
} }
function fold(value) {
return String(value).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
}
function formatMoney(value) {
return Number(value).toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function bindSuggest(input, options) {
if (input.dataset.suggestBound) {
return;
}
input.dataset.suggestBound = '1';
input.setAttribute('autocomplete', 'off');
input.setAttribute('role', 'combobox');
input.setAttribute('aria-autocomplete', 'list');
input.setAttribute('aria-expanded', 'false');
const menu = document.createElement('ul');
menu.className = 'suggest-menu';
menu.hidden = true;
menu.setAttribute('role', 'listbox');
document.body.appendChild(menu);
input._suggestMenu = menu;
let activeIndex = -1;
let visible = [];
function closeMenu() {
menu.hidden = true;
input.setAttribute('aria-expanded', 'false');
activeIndex = -1;
}
function placeMenu() {
const rect = input.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
menu.style.left = Math.max(8, rect.left) + 'px';
menu.style.width = Math.max(rect.width, 220) + 'px';
menu.hidden = false;
const height = menu.offsetHeight;
if (spaceBelow < height + 8 && rect.top > spaceBelow) {
menu.style.top = Math.max(8, rect.top - height - 4) + 'px';
} else {
menu.style.top = (rect.bottom + 4) + 'px';
}
}
function render() {
const query = input.value;
const foldedQuery = fold(query).trim();
let matched = options.items.filter(item => {
if (!foldedQuery) {
return true;
}
const hay = fold(item.label + ' ' + (item.meta || ''));
return foldedQuery.split(/\s+/).every(word => hay.includes(word));
});
if (!options.keepOrder) {
matched.sort((a, b) => {
const score = item => {
if (!foldedQuery) {
return 1;
}
const label = fold(item.label);
if (label.startsWith(foldedQuery)) {
return 0;
}
if (label.includes(foldedQuery)) {
return 1;
}
return 2;
};
return score(a) - score(b);
});
}
visible = matched.slice(0, options.limit || 40);
menu.innerHTML = '';
if (!visible.length) {
if (!foldedQuery && options.freeText) {
closeMenu();
return;
}
const empty = document.createElement('li');
empty.className = 'suggest-empty';
empty.textContent = options.emptyText || 'Nic nenalezeno';
menu.appendChild(empty);
activeIndex = -1;
placeMenu();
input.setAttribute('aria-expanded', 'true');
return;
}
visible.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'suggest-option' + (index === activeIndex ? ' is-active' : '');
li.setAttribute('role', 'option');
li.textContent = item.label;
if (item.meta) {
const meta = document.createElement('small');
meta.textContent = item.meta;
li.appendChild(meta);
}
li.addEventListener('mousedown', event => {
event.preventDefault();
choose(index);
});
li.addEventListener('mouseenter', () => {
activeIndex = index;
menu.querySelectorAll('.suggest-option').forEach((node, i) => {
node.classList.toggle('is-active', i === activeIndex);
});
});
menu.appendChild(li);
});
if (activeIndex >= visible.length) {
activeIndex = visible.length - 1;
}
placeMenu();
input.setAttribute('aria-expanded', 'true');
const current = menu.querySelectorAll('.suggest-option')[activeIndex];
if (current) {
current.classList.add('is-active');
current.scrollIntoView({ block: 'nearest' });
}
}
function choose(index) {
const item = visible[index];
if (!item) {
return;
}
input.value = item.label;
closeMenu();
options.onPick(item, input);
}
function acceptExact() {
const typed = input.value.trim();
if (typed === '') {
options.onClear?.(input);
return;
}
const foldedTyped = fold(typed);
const hits = options.items.filter(item => fold(item.label) === foldedTyped);
if (hits.length === 1) {
input.value = hits[0].label;
options.onPick(hits[0], input);
return;
}
options.onClear?.(input);
}
input.addEventListener('focus', () => {
document.querySelectorAll('.suggest-menu').forEach(other => {
if (other !== menu) {
other.hidden = true;
}
});
activeIndex = -1;
render();
});
input.addEventListener('input', () => {
activeIndex = -1;
if (!options.freeText) {
options.onClear?.(input);
}
render();
});
input.addEventListener('keydown', event => {
if (event.key === 'ArrowDown') {
event.preventDefault();
if (menu.hidden) {
render();
}
if (!visible.length) {
return;
}
activeIndex = (activeIndex + 1) % visible.length;
render();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!visible.length) {
return;
}
activeIndex = activeIndex <= 0 ? visible.length - 1 : activeIndex - 1;
render();
return;
}
if (event.key === 'Enter' && !menu.hidden) {
event.preventDefault();
if (activeIndex >= 0) {
choose(activeIndex);
} else if (visible.length === 1) {
choose(0);
}
return;
}
if (event.key === 'Escape') {
closeMenu();
}
});
input.addEventListener('blur', () => {
window.setTimeout(() => {
if (!options.freeText) {
acceptExact();
}
closeMenu();
}, 120);
});
const followInput = event => {
if (menu.hidden || event.target === menu) {
return;
}
placeMenu();
};
window.addEventListener('resize', followInput);
window.addEventListener('scroll', followInput, true);
}
function initZakaznikSuggest() {
const select = document.querySelector('select[data-suggest="zakaznik"]');
if (!select || select.dataset.locked === '1') {
return;
}
const items = Array.from(select.options)
.filter(option => option.value !== '')
.map(option => ({
value: option.value,
label: option.textContent.trim(),
}));
const wrap = document.createElement('div');
wrap.className = 'suggest';
const input = document.createElement('input');
input.type = 'text';
input.className = 'form-control';
input.placeholder = 'Začněte psát jméno, město nebo IČ';
input.id = select.id ? select.id + '-suggest' : 'zakaznik-suggest';
const label = document.querySelector('label[for="' + select.id + '"]');
if (label) {
label.htmlFor = input.id;
}
select.parentNode.insertBefore(wrap, select);
wrap.appendChild(input);
wrap.appendChild(select);
select.classList.add('suggest-native');
select.setAttribute('aria-hidden', 'true');
select.removeAttribute('required');
select.tabIndex = -1;
const selected = select.selectedOptions[0];
if (select.value && selected) {
input.value = selected.textContent.trim();
}
bindSuggest(input, {
items,
emptyText: 'Žádný zákazník neodpovídá',
onPick(item) {
select.value = item.value;
},
onClear() {
select.value = '';
},
});
}
let katalog = [];
try {
katalog = JSON.parse(document.getElementById('katalog-polozek').textContent || '[]');
} catch (error) {
katalog = [];
}
const katalogItems = katalog.map(item => ({
label: item.nazev,
meta: (item.jednotka ? item.jednotka + ' · ' : '') + formatMoney(item.cena) + ' CZK',
payload: item,
}));
function bindPolozkaSuggest(input) {
if (!input) {
return;
}
input.placeholder = input.placeholder || 'Začněte psát název práce nebo zboží';
bindSuggest(input, {
items: katalogItems,
freeText: true,
keepOrder: true,
emptyText: 'V předchozích fakturách nic podobného není',
onPick(item) {
const row = input.closest('.item-row');
if (!row) {
return;
}
const jednotka = row.querySelector('input[name*="[JEDNOTKA]"]');
const cena = row.querySelector('.item-cena');
if (jednotka && item.payload.jednotka) {
jednotka.value = item.payload.jednotka;
}
if (cena) {
cena.value = Number(item.payload.cena).toFixed(2);
}
calculateTotals();
},
});
}
initZakaznikSuggest();
document.querySelectorAll('.item-nazev').forEach(bindPolozkaSuggest);
// Přidání nového řádku // Přidání nového řádku
btnAdd.addEventListener('click', () => { btnAdd.addEventListener('click', () => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
@ -268,7 +592,7 @@ document.addEventListener('DOMContentLoaded', () => {
<td data-label="Pořadí" class="text-center align-middle drag-handle" style="cursor: grab;"> <td data-label="Pořadí" class="text-center align-middle drag-handle" style="cursor: grab;">
<i class="bi bi-grip-vertical text-muted fs-5"></i> <i class="bi bi-grip-vertical text-muted fs-5"></i>
</td> </td>
<td data-label="Název"><input type="text" name="polozky[${rowIndex}][NAZEV]" class="form-control item-nazev" placeholder="Popis práce / zboží" required></td> <td data-label="Název"><input type="text" name="polozky[${rowIndex}][NAZEV]" class="form-control item-nazev" placeholder="Začněte psát název práce nebo zboží" required autocomplete="off"></td>
<td data-label="Počet"><input type="number" step="0.01" name="polozky[${rowIndex}][POCET]" value="1" class="form-control item-pocet text-end" required></td> <td data-label="Počet"><input type="number" step="0.01" name="polozky[${rowIndex}][POCET]" value="1" class="form-control item-pocet text-end" required></td>
<td data-label="Jedn."><input type="text" name="polozky[${rowIndex}][JEDNOTKA]" value="ks" class="form-control"></td> <td data-label="Jedn."><input type="text" name="polozky[${rowIndex}][JEDNOTKA]" value="ks" class="form-control"></td>
<td data-label="Cena/Jedn."><input type="number" step="0.01" name="polozky[${rowIndex}][CENA_JEDNOTKA]" value="0" class="form-control item-cena text-end" required></td> <td data-label="Cena/Jedn."><input type="number" step="0.01" name="polozky[${rowIndex}][CENA_JEDNOTKA]" value="0" class="form-control item-cena text-end" required></td>
@ -279,6 +603,7 @@ document.addEventListener('DOMContentLoaded', () => {
</td> </td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
bindPolozkaSuggest(tr.querySelector('.item-nazev'));
rowIndex++; rowIndex++;
reindexRows(); reindexRows();
calculateTotals(); calculateTotals();
@ -287,7 +612,9 @@ document.addEventListener('DOMContentLoaded', () => {
// Event delegation pro smazání a přepočet // Event delegation pro smazání a přepočet
tbody.addEventListener('click', (e) => { tbody.addEventListener('click', (e) => {
if (e.target.closest('.btn-remove-row')) { if (e.target.closest('.btn-remove-row')) {
e.target.closest('.item-row').remove(); const row = e.target.closest('.item-row');
row.querySelectorAll('.item-nazev').forEach(input => input._suggestMenu?.remove());
row.remove();
reindexRows(); reindexRows();
calculateTotals(); calculateTotals();
} }

View File

@ -833,3 +833,61 @@ a:hover {
gap: 0.35rem; gap: 0.35rem;
} }
} }
/* Našeptávač zákazníka a položek faktury */
.suggest {
position: relative;
}
select.suggest-native {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
pointer-events: none !important;
}
.suggest-menu {
position: fixed;
z-index: 1080;
max-height: 280px;
margin: 0;
padding: 0.25rem 0;
overflow-y: auto;
list-style: none;
background: #fff;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(20, 50, 100, 0.14);
}
.suggest-menu[hidden] {
display: none !important;
}
.suggest-option {
padding: 0.4rem 0.75rem;
cursor: pointer;
line-height: 1.3;
}
.suggest-option.is-active,
.suggest-option:hover {
background: var(--primary-soft);
}
.suggest-option small {
display: block;
color: var(--text-muted);
font-size: 0.78rem;
}
.suggest-empty {
padding: 0.45rem 0.75rem;
color: var(--text-muted);
}