Admin menu.

master
Kowalski 2026-10-31 09:31:03 +01:00
parent fa02f23d7f
commit 497708b5bb
10 changed files with 1015 additions and 152 deletions

View File

@ -2,35 +2,188 @@
namespace App\Model\Facade;
use App\Model\Database\KufrikConnectionFactory;
use Nette\Database\Connection;
use Nette\Http\FileUpload;
use Nette\Security\Passwords;
use Nette\Utils\FileSystem;
use Nette\Utils\Json;
use Nette\Utils\Validators;
class DatabazeFacade
{
private const MASTER_DB = 'kufrik_master';
private const SYSTEM_DBS = ['master', 'tempdb', 'model', 'msdb', self::MASTER_DB];
/** Extended property na datové DB — jen takto označené se vypisují v Admin/Databáze. */
private const APP_PROPERTY = 'kufrik_app';
private const APP_PROPERTY_VALUE = '1';
public function __construct(
private Connection $masterDb,
private string $sdfStorageDir
private string $sdfStorageDir,
private Passwords $passwords,
private KufrikConnectionFactory $kufrikConnectionFactory,
private string $tenantServer,
private string $tenantDatabase
) {
}
/**
* Seznam databází z master tabulky LOGIN (jeden login = jedna databáze).
* Seznam datových databází Kufříku (označené extended property kufrik_app=1).
*
* @return \stdClass[]
*/
public function getAll(): array
public function getDatabases(): array
{
$rows = $this->masterDb->fetchAll(
"SELECT name AS [DATABASE]
FROM sys.databases
WHERE state = 0
AND name NOT IN (?, ?, ?, ?, ?)
ORDER BY name",
...self::SYSTEM_DBS
);
$stats = [];
foreach ($this->masterDb->fetchAll(
"SELECT [DATABASE], [SERVER],
COUNT(*) AS USERS,
COUNT(DISTINCT FIRMA_ID) AS FIRMY
FROM dbo.LOGIN
WHERE [DATABASE] <> ?
GROUP BY [DATABASE], [SERVER]",
self::MASTER_DB
) as $row) {
$stats[(string) $row->DATABASE] = $row;
}
$result = [];
foreach ($rows as $row) {
$name = (string) $row->DATABASE;
if (!$this->isKufrikDatabase($name)) {
continue;
}
$stat = $stats[$name] ?? null;
$result[] = (object) [
'DATABASE' => $name,
'SERVER' => $stat->SERVER ?? $this->tenantServer,
'USERS' => (int) ($stat->USERS ?? 0),
'FIRMY' => (int) ($stat->FIRMY ?? 0),
];
}
return $result;
}
/**
* @return array<string, string> database => database
*/
public function getDatabaseSelectItems(): array
{
$items = [];
foreach ($this->getDatabases() as $db) {
$items[$db->DATABASE] = $db->DATABASE;
}
if ($items === [] && $this->tenantDatabase !== '') {
$items[$this->tenantDatabase] = $this->tenantDatabase;
}
return $items;
}
/**
* Firmy z vybrané datové DB.
*
* @return \stdClass[]
*/
public function getFirmy(string $database, ?string $server = null): array
{
$server ??= $this->tenantServer;
$this->assertTenantDatabase($database);
$explorer = $this->kufrikConnectionFactory->createTenantExplorer($server, $database, 1);
$userCounts = [];
foreach ($this->masterDb->fetchAll(
'SELECT FIRMA_ID, COUNT(*) AS C
FROM dbo.LOGIN
WHERE [DATABASE] = ? AND [SERVER] = ?
GROUP BY FIRMA_ID',
$database,
$server
) as $row) {
$userCounts[(int) $row->FIRMA_ID] = (int) $row->C;
}
$result = [];
foreach ($explorer->table('FIRMA')->order('NAZEV ASC') as $firma) {
$firmaId = (int) $firma->ID;
$loginId = $this->findLoginIdForFirma($database, $firmaId, $server);
$item = (object) [
'ID' => $firmaId,
'NAZEV' => (string) $firma->NAZEV,
'IC' => $firma->IC,
'DIC' => $firma->DIC,
'EMAIL' => $firma->EMAIL,
'TELEFON' => $firma->TELEFON,
'MESTO' => $firma->MESTO,
'DATABASE' => $database,
'SERVER' => $server,
'USERS' => $userCounts[$firmaId] ?? 0,
'LOGIN_ID' => $loginId,
];
if ($loginId) {
$meta = $this->readSdfMeta($loginId);
$item->SDF_SOUBOR = $meta['originalName'] ?? null;
$item->SDF_NAHRANO = isset($meta['uploadedAt']) ? new \DateTimeImmutable($meta['uploadedAt']) : null;
$item->SDF_IMPORTOVANO = isset($meta['importedAt']) ? new \DateTimeImmutable($meta['importedAt']) : null;
} else {
$item->SDF_SOUBOR = null;
$item->SDF_NAHRANO = null;
$item->SDF_IMPORTOVANO = null;
}
$result[] = $item;
}
return $result;
}
/**
* @return array<int, string>
*/
public function getFirmaSelectItems(string $database, ?string $server = null): array
{
$items = [];
foreach ($this->getFirmy($database, $server) as $firma) {
$items[(int) $firma->ID] = $firma->NAZEV . ' (#' . $firma->ID . ')';
}
return $items;
}
/**
* Uživatelé z master tabulky LOGIN.
*
* @return \stdClass[]
*/
public function getUzivatele(): array
{
$rows = $this->masterDb->fetchAll(
'SELECT ID, EMAIL, POSLEDNI_PRIHLASENI, AKTIVNI, [DATABASE], [SERVER], FIRMA_ID, IS_ADMIN
FROM dbo.LOGIN
ORDER BY [DATABASE], EMAIL'
ORDER BY [DATABASE], FIRMA_ID, EMAIL'
);
$firmaNamesByDb = [];
$result = [];
foreach ($rows as $row) {
$result[] = $this->hydrate($row);
$item = $this->hydrateLogin($row);
$db = (string) $item->DATABASE;
if (!isset($firmaNamesByDb[$db])) {
$firmaNamesByDb[$db] = $this->loadFirmaNames($db, (string) $item->SERVER);
}
$item->FIRMA_NAZEV = $firmaNamesByDb[$db][(int) $item->FIRMA_ID] ?? null;
$result[] = $item;
}
return $result;
@ -44,8 +197,129 @@ class DatabazeFacade
WHERE ID = ?',
$id
);
if (!$row) {
return null;
}
return $row ? $this->hydrate($row) : null;
$item = $this->hydrateLogin($row);
$names = $this->loadFirmaNames((string) $item->DATABASE, (string) $item->SERVER);
$item->FIRMA_NAZEV = $names[(int) $item->FIRMA_ID] ?? null;
return $item;
}
public function createFirma(string $firmaNazev, string $database, ?string $server = null): int
{
$server ??= $this->tenantServer;
$firmaNazev = trim($firmaNazev);
$this->assertTenantDatabase($database);
if ($firmaNazev === '') {
throw new \InvalidArgumentException('Zadejte název firmy.');
}
$explorer = $this->kufrikConnectionFactory->createTenantExplorer($server, $database, 1);
$firmaRow = $explorer->table('FIRMA')->insert([
'NAZEV' => mb_substr($firmaNazev, 0, 255),
'PLATCE_DPH' => 0,
]);
return (int) $firmaRow->ID;
}
/**
* Založí uživatele (vždy role User, nikdy admin).
*/
public function createUzivatel(
string $email,
string $password,
string $database,
int $firmaId,
?string $server = null
): int {
$server ??= $this->tenantServer;
$email = mb_strtolower(trim($email));
$this->assertTenantDatabase($database);
if (!Validators::isEmail($email)) {
throw new \InvalidArgumentException('Zadejte platný e-mail uživatele.');
}
if (mb_strlen($password) < 6) {
throw new \InvalidArgumentException('Heslo musí mít alespoň 6 znaků.');
}
if ($this->emailExists($email)) {
throw new \InvalidArgumentException('Uživatel s tímto e-mailem už existuje.');
}
if (!$this->firmaExists($database, $firmaId, $server)) {
throw new \InvalidArgumentException('Vybraná firma v databázi neexistuje.');
}
$this->masterDb->query(
'INSERT INTO dbo.LOGIN (EMAIL, HESLO_HASH, AKTIVNI, [DATABASE], [SERVER], FIRMA_ID, IS_ADMIN)
VALUES (?, ?, 1, ?, ?, ?, 0)',
$email,
$this->passwords->hash($password),
$database,
$server,
$firmaId
);
$loginId = (int) $this->masterDb->fetchField('SELECT ID FROM dbo.LOGIN WHERE EMAIL = ?', $email);
if (!$loginId) {
throw new \RuntimeException('Uživatele se nepodařilo založit.');
}
return $loginId;
}
public function updateUserCredentials(int $loginId, string $email, ?string $password = null): void
{
$login = $this->getById($loginId);
if (!$login) {
throw new \InvalidArgumentException('Uživatel nebyl nalezen.');
}
$email = mb_strtolower(trim($email));
if (!Validators::isEmail($email)) {
throw new \InvalidArgumentException('Zadejte platný e-mail uživatele.');
}
if ($this->emailExists($email, $loginId)) {
throw new \InvalidArgumentException('Uživatel s tímto e-mailem už existuje.');
}
if ($password !== null && $password !== '') {
if (mb_strlen($password) < 6) {
throw new \InvalidArgumentException('Heslo musí mít alespoň 6 znaků.');
}
$this->masterDb->query(
'UPDATE dbo.LOGIN SET EMAIL = ?, HESLO_HASH = ? WHERE ID = ?',
$email,
$this->passwords->hash($password),
$loginId
);
return;
}
$this->masterDb->query(
'UPDATE dbo.LOGIN SET EMAIL = ? WHERE ID = ?',
$email,
$loginId
);
}
public function findLoginIdForFirma(string $database, int $firmaId, ?string $server = null): ?int
{
$server ??= $this->tenantServer;
$id = $this->masterDb->fetchField(
'SELECT TOP 1 ID FROM dbo.LOGIN
WHERE [DATABASE] = ? AND [SERVER] = ? AND FIRMA_ID = ?
ORDER BY IS_ADMIN DESC, ID ASC',
$database,
$server,
$firmaId
);
return $id ? (int) $id : null;
}
public function getSdfFilePath(int $loginId): ?string
@ -66,29 +340,11 @@ class DatabazeFacade
FileSystem::write($this->sdfMetaPath($loginId), Json::encode($meta, pretty: true));
}
/**
* Položky selectu: ID => „databáze — e-mail“.
*
* @return array<int, string>
*/
public function getSelectItems(): array
{
$items = [];
foreach ($this->getAll() as $login) {
$items[(int) $login->ID] = $login->DATABASE . ' — ' . $login->EMAIL;
}
return $items;
}
/**
* Uloží nahraný .sdf k loginu.
*/
public function saveSdf(int $loginId, FileUpload $file): void
{
$exists = $this->masterDb->fetchField('SELECT ID FROM dbo.LOGIN WHERE ID = ?', $loginId);
if (!$exists) {
throw new \InvalidArgumentException('Vybraná databáze (login) neexistuje.');
throw new \InvalidArgumentException('Uživatel / login pro import neexistuje.');
}
if (!$file->isOk()) {
@ -101,7 +357,6 @@ class DatabazeFacade
}
FileSystem::createDir($this->sdfStorageDir);
$file->move($this->sdfPath($loginId));
$this->clearSdfImportMeta($loginId);
@ -111,10 +366,95 @@ class DatabazeFacade
'size' => $file->getSize(),
'loginId' => $loginId,
];
FileSystem::write($this->sdfMetaPath($loginId), Json::encode($meta, pretty: true));
}
public function getDefaultTenantDatabase(): string
{
return $this->tenantDatabase;
}
public function getDefaultTenantServer(): string
{
return $this->tenantServer;
}
private function assertTenantDatabase(string $database): void
{
if ($database === '' || in_array($database, self::SYSTEM_DBS, true)) {
throw new \InvalidArgumentException('Neplatná datová databáze.');
}
if (!$this->isKufrikDatabase($database)) {
throw new \InvalidArgumentException(
"Databáze „{$database}“ není označená jako Kufřík (chybí metadata kufrik_app)."
);
}
}
/**
* Databáze patří do Kufříku, pokud má extended property kufrik_app = 1.
*/
private function isKufrikDatabase(string $database): bool
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $database)) {
return false;
}
try {
// sqlsrv + Nette fetchField nefunguje spolehlivě (column '0') → pojmenovaný sloupec
$row = $this->masterDb->fetch(
"SELECT TOP 1 CONVERT(nvarchar(100), value) AS prop_value
FROM [{$database}].sys.extended_properties
WHERE class = 0 AND major_id = 0 AND minor_id = 0 AND name = ?",
self::APP_PROPERTY
);
return $row !== null && (string) $row->prop_value === self::APP_PROPERTY_VALUE;
} catch (\Throwable) {
return false;
}
}
private function firmaExists(string $database, int $firmaId, string $server): bool
{
$explorer = $this->kufrikConnectionFactory->createTenantExplorer($server, $database, 1);
return (bool) $explorer->table('FIRMA')->get($firmaId);
}
private function emailExists(string $email, ?int $exceptId = null): bool
{
if ($exceptId) {
return (bool) $this->masterDb->fetchField(
'SELECT ID FROM dbo.LOGIN WHERE EMAIL = ? AND ID <> ?',
$email,
$exceptId
);
}
return (bool) $this->masterDb->fetchField(
'SELECT ID FROM dbo.LOGIN WHERE EMAIL = ?',
$email
);
}
/**
* @return array<int, string>
*/
private function loadFirmaNames(string $database, string $server): array
{
try {
$this->assertTenantDatabase($database);
$explorer = $this->kufrikConnectionFactory->createTenantExplorer($server, $database, 1);
$names = [];
foreach ($explorer->table('FIRMA')->select('ID, NAZEV') as $firma) {
$names[(int) $firma->ID] = (string) $firma->NAZEV;
}
return $names;
} catch (\Throwable) {
return [];
}
}
/**
* @return array{originalName?: string, uploadedAt?: string, size?: int}|array{}
*/
@ -143,7 +483,7 @@ class DatabazeFacade
return $meta;
}
private function hydrate(object $row): \stdClass
private function hydrateLogin(object $row): \stdClass
{
$item = (object) (array) $row;
$meta = $this->readSdfMeta((int) $item->ID);

View File

@ -235,9 +235,13 @@
<i class="bi bi-database-fill"></i>
<span>Databáze</span>
</a>
<a n:href="Admin:statistika" n:class="btn, btn-action, $presenter->isLinkCurrent('Admin:statistika') ? 'btn-primary active' : 'btn-outline-primary'">
<i class="bi bi-graph-up"></i>
<span>Statistika</span>
<a n:href="Admin:firmy" n:class="btn, btn-action, $presenter->isLinkCurrent('Admin:firmy') ? 'btn-primary active' : 'btn-outline-primary'">
<i class="bi bi-building"></i>
<span>Firmy</span>
</a>
<a n:href="Admin:uzivatele" n:class="btn, btn-action, $presenter->isLinkCurrent('Admin:uzivatele') ? 'btn-primary active' : 'btn-outline-primary'">
<i class="bi bi-people"></i>
<span>Uživatelé</span>
</a>
</div>
</div>

View File

@ -15,6 +15,9 @@ class AdminPresenter extends BasePresenter
{
private const SDF_MAX_BYTES = 50 * 1024 * 1024;
/** @persistent */
public string $db = '';
public function __construct(
private DatabazeFacade $databazeFacade,
private SdfKufrikImporter $sdfImporter
@ -29,6 +32,10 @@ class AdminPresenter extends BasePresenter
if (!$this->getUser()->isInRole('admin')) {
$this->error('Sekce Admin je dostupná pouze administrátorovi systému.', 403);
}
if ($this->db === '') {
$this->db = $this->databazeFacade->getDefaultTenantDatabase();
}
}
public function actionDefault(): void
@ -38,22 +45,192 @@ class AdminPresenter extends BasePresenter
public function renderDatabaze(): void
{
$loginy = $this->databazeFacade->getAll();
$aktivniCount = 0;
$databases = $this->databazeFacade->getDatabases();
$this->template->databases = $databases;
$this->template->celkemCount = count($databases);
}
foreach ($loginy as $login) {
if ($login->AKTIVNI) {
public function renderFirmy(): void
{
$databases = $this->databazeFacade->getDatabaseSelectItems();
if ($databases !== [] && !isset($databases[$this->db])) {
$this->db = (string) array_key_first($databases);
}
$firmy = $this->db !== ''
? $this->databazeFacade->getFirmy($this->db, $this->databazeFacade->getDefaultTenantServer())
: [];
$this->template->databases = $databases;
$this->template->selectedDb = $this->db;
$this->template->firmy = $firmy;
$this->template->celkemCount = count($firmy);
}
public function renderUzivatele(): void
{
$uzivatele = $this->databazeFacade->getUzivatele();
$aktivniCount = 0;
foreach ($uzivatele as $u) {
if ($u->AKTIVNI) {
$aktivniCount++;
}
}
$this->template->loginy = $loginy;
$this->template->celkemCount = count($loginy);
$this->template->uzivatele = $uzivatele;
$this->template->celkemCount = count($uzivatele);
$this->template->aktivniCount = $aktivniCount;
$this->template->databases = $this->databazeFacade->getDatabaseSelectItems();
$this->template->defaultDb = $this->db !== ''
? $this->db
: $this->databazeFacade->getDefaultTenantDatabase();
$this->template->firmaItems = $this->databazeFacade->getFirmaSelectItems(
$this->template->defaultDb,
$this->databazeFacade->getDefaultTenantServer()
);
}
public function renderStatistika(): void
protected function createComponentAddFirmaForm(): Form
{
$form = new Form();
$form->addText('firmaNazev', 'Název firmy / OSVČ:')
->setRequired('Zadejte název firmy.')
->addRule($form::MaxLength, 'Název může mít nejvýše 255 znaků.', 255);
$form->addSubmit('send', 'Přidat firmu');
$form->onSuccess[] = [$this, 'addFirmaFormSucceeded'];
return $form;
}
public function addFirmaFormSucceeded(Form $form, \stdClass $data): void
{
try {
if ($this->db === '') {
throw new \InvalidArgumentException('Nejdřív vyberte databázi.');
}
$firmaId = $this->databazeFacade->createFirma(
$data->firmaNazev,
$this->db,
$this->databazeFacade->getDefaultTenantServer()
);
$this->flashMessage("Firma „{$data->firmaNazev}“ (#{$firmaId}) byla založena.", 'success');
} catch (AbortException $e) {
throw $e;
} catch (\Throwable $e) {
$form->addError($e->getMessage());
return;
}
$this->redirect('this');
}
protected function createComponentAddUzivatelForm(): Form
{
$form = new Form();
$databases = $this->databazeFacade->getDatabaseSelectItems();
$defaultDb = isset($databases[$this->db])
? $this->db
: ($databases !== [] ? (string) array_key_first($databases) : '');
$form->addSelect('database', 'Databáze:')
->setItems($databases)
->setDefaultValue($defaultDb)
->setRequired('Vyberte databázi.');
$form->addSelect('firmaId', 'Firma:')
->setItems($defaultDb !== ''
? $this->databazeFacade->getFirmaSelectItems($defaultDb)
: [])
->setPrompt('— vyberte firmu —')
->setRequired('Vyberte firmu.');
$form->addEmail('email', 'E-mail:')
->setRequired('Zadejte e-mail.')
->addRule($form::MaxLength, 'E-mail může mít nejvýše 100 znaků.', 100);
$form->addPassword('password', 'Heslo:')
->setRequired('Zadejte heslo.')
->addRule($form::MinLength, 'Heslo musí mít alespoň 6 znaků.', 6);
$form->addPassword('passwordVerify', 'Heslo znovu:')
->setRequired('Zadejte heslo znovu.')
->addRule($form::Equal, 'Hesla se neshodují.', $form['password']);
$form->addSubmit('send', 'Přidat uživatele');
$form->onSuccess[] = [$this, 'addUzivatelFormSucceeded'];
return $form;
}
public function addUzivatelFormSucceeded(Form $form, \stdClass $data): void
{
try {
$this->databazeFacade->createUzivatel(
$data->email,
$data->password,
$data->database,
(int) $data->firmaId,
$this->databazeFacade->getDefaultTenantServer()
);
$this->flashMessage('Uživatel byl založen s rolí User.', 'success');
} catch (AbortException $e) {
throw $e;
} catch (\Throwable $e) {
$form->addError($e->getMessage());
return;
}
$this->redirect('this');
}
protected function createComponentUserForm(): Form
{
$form = new Form();
$form->addHidden('loginId')
->addRule($form::Integer)
->setRequired();
$form->addEmail('email', 'E-mail / přihlašovací jméno:')
->setRequired('Zadejte e-mail.')
->addRule($form::MaxLength, 'E-mail může mít nejvýše 100 znaků.', 100);
$form->addPassword('password', 'Nové heslo:')
->setNullable()
->addCondition($form::Filled)
->addRule($form::MinLength, 'Heslo musí mít alespoň 6 znaků.', 6);
$form->addPassword('passwordVerify', 'Heslo znovu:')
->setNullable()
->addConditionOn($form['password'], $form::Filled)
->setRequired('Zadejte heslo znovu.')
->addRule($form::Equal, 'Hesla se neshodují.', $form['password']);
$form->addSubmit('send', 'Uložit uživatele');
$form->onSuccess[] = [$this, 'userFormSucceeded'];
return $form;
}
public function userFormSucceeded(Form $form, \stdClass $data): void
{
try {
$password = $data->password !== '' ? $data->password : null;
$this->databazeFacade->updateUserCredentials(
(int) $data->loginId,
$data->email,
$password
);
$this->flashMessage('Údaje uživatele byly uloženy.', 'success');
} catch (AbortException $e) {
throw $e;
} catch (\Throwable $e) {
$this->flashMessage('Uložení uživatele selhalo: ' . $e->getMessage(), 'danger');
}
$this->redirect('this');
}
protected function createComponentSdfImportForm(): Form
@ -61,8 +238,8 @@ class AdminPresenter extends BasePresenter
$form = new Form();
$form->addHidden('loginId')
->addRule($form::Integer, 'Vyberte databázi.')
->setRequired('Vyberte databázi.');
->addRule($form::Integer, 'Vyberte firmu s uživatelem.')
->setRequired('Vyberte firmu s uživatelem.');
$iniLimits = array_filter([
Helpers::iniGetSize('upload_max_filesize'),
@ -102,13 +279,11 @@ class AdminPresenter extends BasePresenter
$stats = $this->sdfImporter->import($loginId);
$this->flashMessage(
sprintf(
'Import ze SDF do databáze %s dokončen. Původní data byla smazána. Zákazníci: %d. Faktury: %d. Položky: %d. Platby: %d. Nastavení: %s.',
$stats['database'],
'Import ze SDF dokončen. Původní data firmy byla smazána. Zákazníci: %d. Faktury: %d. Položky: %d. Platby: %d.',
$stats['zakazniciInserted'],
$stats['fakturyInserted'],
$stats['polozkyInserted'],
$stats['platbyInserted'],
!empty($stats['nastaveniImported']) ? 'přeneseno' : 'bez změn'
$stats['platbyInserted']
),
'success'
);

View File

@ -1,38 +1,22 @@
{block content}
<!-- DataTables CSS s Bootstrap 5 vzhledem -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.6/css/dataTables.bootstrap5.min.css">
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h3 mb-0">Přehled databází</h2>
</div>
<form n:name="sdfImportForm" class="d-none">
<input n:name="loginId">
<input n:name="sdf" accept=".sdf">
<button n:name="send">Import</button>
</form>
<div class="alert alert-danger" n:if="$presenter['sdfImportForm']->hasErrors()">
<ul class="mb-0 ps-3">
<li n:foreach="$presenter['sdfImportForm']->errors as $error">{$error}</li>
</ul>
<div class="mb-3">
<h2 class="h3 mb-1">Databáze</h2>
<p class="text-muted small mb-0">
Jen databáze označené metadaty <code>kufrik_app=1</code> (bez systémových DB a <code>kufrik_master</code>).
</p>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6 col-lg-4">
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-primary border-4">
<div class="text-muted small">Celkem databází</div>
<div class="text-muted small">Databází</div>
<div class="fs-4 fw-bold text-primary">{$celkemCount} ks</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-success border-4">
<div class="text-muted small">Aktivní</div>
<div class="fs-4 fw-bold text-success">{$aktivniCount} ks</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0 p-3 bg-white">
@ -42,71 +26,27 @@
<tr>
<th>Databáze</th>
<th>Server</th>
<th>Login</th>
<th class="text-center">Firma ID</th>
<th>Poslední přihlášení</th>
<th class="text-center">Stav</th>
<th class="text-center">Role</th>
<th>Poslední import SDF</th>
<th class="text-center">Firem</th>
<th class="text-center">Uživatelů</th>
<th class="text-end" data-orderable="false">Akce</th>
</tr>
</thead>
<tbody>
{foreach $loginy as $login}
<tr n:class="!$login->AKTIVNI ? 'table-light text-muted'">
<td data-label="Databáze" class="fw-bold" data-order="{$login->DATABASE}">
<i class="bi bi-database me-1 text-muted"></i>{$login->DATABASE}
</td>
<td data-label="Server">{$login->SERVER}</td>
<td data-label="Login">
<a href="mailto:{$login->EMAIL}" class="text-decoration-none">{$login->EMAIL}</a>
</td>
<td data-label="Firma ID" class="text-center">{$login->FIRMA_ID}</td>
<td data-label="Poslední přihlášení" data-order="{if $login->POSLEDNI_PRIHLASENI}{$login->POSLEDNI_PRIHLASENI|date:'YmdHis'}{/if}">
{if $login->POSLEDNI_PRIHLASENI}
{$login->POSLEDNI_PRIHLASENI|date:'d.m.Y H:i'}
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td data-label="Stav" class="text-center" data-order="{$login->AKTIVNI}">
{if $login->AKTIVNI}
<span class="badge bg-success">Aktivní</span>
{else}
<span class="badge bg-secondary">Neaktivní</span>
{/if}
</td>
<td data-label="Role" class="text-center" data-order="{$login->IS_ADMIN}">
{if $login->IS_ADMIN}
<span class="badge bg-dark">Admin</span>
{else}
<span class="badge bg-light text-dark border">Uživatel</span>
{/if}
</td>
<td data-label="Poslední import SDF" data-order="{$login->SDF_SOUBOR}">
{if $login->SDF_SOUBOR}
<div class="fw-semibold">{$login->SDF_SOUBOR}</div>
<div class="small text-muted">
nahráno {$login->SDF_NAHRANO|date:'d.m.Y H:i'}
{if $login->SDF_VELIKOST_TEXT}
· {$login->SDF_VELIKOST_TEXT}
{/if}
</div>
<div class="small text-success" n:if="$login->SDF_IMPORTOVANO">
importováno {$login->SDF_IMPORTOVANO|date:'d.m.Y H:i'}
</div>
{else}
<span class="text-muted">—</span>
{/if}
{foreach $databases as $db}
<tr>
<td data-label="Databáze" class="fw-bold">
<i class="bi bi-database me-1 text-muted"></i>{$db->DATABASE}
</td>
<td data-label="Server">{$db->SERVER}</td>
<td data-label="Firem" class="text-center">{$db->FIRMY}</td>
<td data-label="Uživatelů" class="text-center">{$db->USERS}</td>
<td class="text-end text-nowrap cell-actions">
<button type="button"
class="btn btn-sm btn-success js-sdf-import"
data-login-id="{$login->ID}"
data-database="{$login->DATABASE}"
title="Smaže stávající zákazníky, faktury, položky a platby a nahradí je daty ze SDF">
<i class="bi bi-box-arrow-in-down me-1"></i>Import
</button>
<a n:href="Admin:firmy, db => $db->DATABASE" class="btn btn-sm btn-outline-primary me-1" title="Firmy v databázi">
<i class="bi bi-building"></i>
</a>
<a n:href="Admin:uzivatele, db => $db->DATABASE" class="btn btn-sm btn-outline-secondary" title="Uživatelé databáze">
<i class="bi bi-people"></i>
</a>
</td>
</tr>
{/foreach}
@ -123,33 +63,11 @@
<script>
$(document).ready(function() {
$('#databaze-table').DataTable({
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/cs.json'
},
language: { url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/cs.json' },
order: [[0, 'asc']],
pageLength: 25,
autoWidth: false
});
const $form = $('form[id$="-sdfImportForm"], #frm-sdfImportForm').first();
const $loginId = $form.find('[name="loginId"]');
const $file = $form.find('input[type="file"]');
$(document).on('click', '.js-sdf-import', function() {
const database = $(this).data('database');
if (!confirm('VAROVÁNÍ: Import ze SDF do databáze ' + database + ' SMAŽE všechna stávající data této firmy (zákazníky, faktury, položky a platby) a nahradí je obsahem souboru.\n\nOpravdu chcete pokračovat?')) {
return;
}
$loginId.val($(this).data('login-id'));
$file.val('');
$file.trigger('click');
});
$file.on('change', function() {
if (this.files && this.files.length) {
$form.trigger('submit');
}
});
});
</script>
{/syntax}

166
app/UI/Admin/firmy.latte Normal file
View File

@ -0,0 +1,166 @@
{block content}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.6/css/dataTables.bootstrap5.min.css">
<div class="container-fluid py-3">
<div class="mb-3">
<h2 class="h3 mb-1">Firmy</h2>
<p class="text-muted small mb-0">
Tabulka <code>FIRMA</code> ve vybrané datové databázi.
</p>
</div>
<div class="card border-0 shadow-sm p-3 bg-white mb-4">
<div class="mb-2" n:if="$presenter['addFirmaForm']->hasErrors()">
<div class="alert alert-danger mb-0">
<ul class="mb-0 ps-3">
<li n:foreach="$presenter['addFirmaForm']->errors as $error">{$error}</li>
</ul>
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-md-3">
<form method="get" action="{link this}">
<label class="form-label" for="dbFilter">Databáze</label>
<select name="db" id="dbFilter" class="form-select" onchange="this.form.submit()">
<option n:foreach="$databases as $name => $label" value="{$name}" {if $selectedDb === $name}selected{/if}>{$label}</option>
</select>
</form>
</div>
<div class="col-md-9">
<form n:name="addFirmaForm" class="row g-2 align-items-end">
<div class="col-md-8">
<label n:name="firmaNazev" class="form-label">Název firmy / OSVČ</label>
<input n:name="firmaNazev" class="form-control" placeholder="např. Jan Novák">
</div>
<div class="col-md-4">
<button n:name="send" class="btn btn-primary w-100">
<i class="bi bi-plus-lg me-1"></i>Přidat
</button>
</div>
</form>
</div>
</div>
</div>
<form n:name="sdfImportForm" class="d-none">
<input n:name="loginId">
<input n:name="sdf" accept=".sdf">
<button n:name="send">Import</button>
</form>
<div class="row g-3 mb-4">
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-primary border-4">
<div class="text-muted small">Firem v {$selectedDb}</div>
<div class="fs-4 fw-bold text-primary">{$celkemCount} ks</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0 p-3 bg-white">
<div class="table-responsive">
<table class="table table-hover table-cards align-middle w-100" id="firmy-table">
<thead class="table-light">
<tr>
<th>Název</th>
<th class="text-center">ID</th>
<th>IČ / DIČ</th>
<th>Kontakt</th>
<th class="text-center">Uživatelů</th>
<th>SDF</th>
<th class="text-end" data-orderable="false">Akce</th>
</tr>
</thead>
<tbody>
{foreach $firmy as $firma}
<tr>
<td data-label="Název" class="fw-bold">
<i class="bi bi-building me-1 text-muted"></i>{$firma->NAZEV}
</td>
<td data-label="ID" class="text-center">{$firma->ID}</td>
<td data-label="IČ / DIČ">
{if $firma->IC || $firma->DIC}
<div n:if="$firma->IC">IČ: {$firma->IC}</div>
<div class="small text-muted" n:if="$firma->DIC">DIČ: {$firma->DIC}</div>
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td data-label="Kontakt">
{if $firma->EMAIL || $firma->TELEFON || $firma->MESTO}
<div n:if="$firma->EMAIL">{$firma->EMAIL}</div>
<div class="small text-muted" n:if="$firma->TELEFON">{$firma->TELEFON}</div>
<div class="small text-muted" n:if="$firma->MESTO">{$firma->MESTO}</div>
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td data-label="Uživatelů" class="text-center">{$firma->USERS}</td>
<td data-label="SDF">
{if $firma->SDF_SOUBOR}
<div class="fw-semibold">{$firma->SDF_SOUBOR}</div>
<div class="small text-success" n:if="$firma->SDF_IMPORTOVANO">
importováno {$firma->SDF_IMPORTOVANO|date:'d.m.Y H:i'}
</div>
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td class="text-end text-nowrap cell-actions">
{if $firma->LOGIN_ID}
<button type="button"
class="btn btn-sm btn-success js-sdf-import"
data-login-id="{$firma->LOGIN_ID}"
data-firma="{$firma->NAZEV}"
title="Import ze SDF (smaže data firmy)">
<i class="bi bi-box-arrow-in-down me-1"></i>Import
</button>
{else}
<span class="small text-muted">Nejdřív založte uživatele</span>
{/if}
</td>
</tr>
{/foreach}
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/datatables.net@1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.6/js/dataTables.bootstrap5.min.js"></script>
{syntax off}
<script>
$(document).ready(function() {
$('#firmy-table').DataTable({
language: { url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/cs.json' },
order: [[0, 'asc']],
pageLength: 25,
autoWidth: false
});
const $importForm = $('form[id$="-sdfImportForm"], #frm-sdfImportForm').first();
const $importLoginId = $importForm.find('[name="loginId"]');
const $importFile = $importForm.find('input[type="file"]');
$(document).on('click', '.js-sdf-import', function() {
const firma = $(this).data('firma');
if (!confirm('VAROVÁNÍ: Import ze SDF pro firmu ' + firma + ' SMAŽE všechna stávající data této firmy.\n\nOpravdu pokračovat?')) {
return;
}
$importLoginId.val($(this).data('login-id'));
$importFile.val('');
$importFile.trigger('click');
});
$importFile.on('change', function() {
if (this.files && this.files.length) {
$importForm.trigger('submit');
}
});
});
</script>
{/syntax}

View File

@ -0,0 +1,206 @@
{block content}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.6/css/dataTables.bootstrap5.min.css">
<div class="container-fluid py-3">
<div class="mb-3">
<h2 class="h3 mb-1">Uživatelé</h2>
<p class="text-muted small mb-0">
Tabulka <code>LOGIN</code> v <code>kufrik_master</code>. Každý uživatel míří na databázi a firmu. Noví uživatelé mají vždy roli User.
</p>
</div>
<div class="card border-0 shadow-sm p-3 bg-white mb-4">
<h3 class="h6 text-muted mb-2">Přidat uživatele</h3>
<form n:name="addUzivatelForm" class="row g-3 align-items-end">
<div class="col-12" n:if="$form->hasErrors()">
<div class="alert alert-danger mb-0">
<ul class="mb-0 ps-3">
<li n:foreach="$form->errors as $error">{$error}</li>
</ul>
</div>
</div>
<div class="col-md-2">
<label n:name="database" class="form-label">Databáze</label>
<select n:name="database" class="form-select" id="addUserDatabase"></select>
</div>
<div class="col-md-3">
<label n:name="firmaId" class="form-label">Firma</label>
<select n:name="firmaId" class="form-select" id="addUserFirma"></select>
</div>
<div class="col-md-3">
<label n:name="email" class="form-label">E-mail</label>
<input n:name="email" class="form-control" placeholder="jan@firma.cz">
</div>
<div class="col-md-2">
<label n:name="password" class="form-label">Heslo</label>
<input n:name="password" class="form-control">
</div>
<div class="col-md-2">
<label n:name="passwordVerify" class="form-label">Heslo znovu</label>
<input n:name="passwordVerify" class="form-control">
</div>
<div class="col-12 col-md-auto">
<button n:name="send" class="btn btn-primary">
<i class="bi bi-person-plus me-1"></i>Přidat
</button>
</div>
</form>
<p class="small text-muted mt-2 mb-0">
Po změně databáze obnovte stránku s parametrem <code>?db=...</code>, aby se načetly firmy, nebo přidejte firmu nejdřív v agendě Firmy.
</p>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-primary border-4">
<div class="text-muted small">Uživatelů</div>
<div class="fs-4 fw-bold text-primary">{$celkemCount} ks</div>
</div>
</div>
<div class="col-md-4 col-lg-3">
<div class="card border-0 shadow-sm p-3 bg-white border-start border-success border-4">
<div class="text-muted small">Aktivní</div>
<div class="fs-4 fw-bold text-success">{$aktivniCount} ks</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0 p-3 bg-white">
<div class="table-responsive">
<table class="table table-hover table-cards align-middle w-100" id="uzivatele-table">
<thead class="table-light">
<tr>
<th>E-mail</th>
<th>Databáze</th>
<th>Firma</th>
<th class="text-center">Role</th>
<th>Poslední přihlášení</th>
<th class="text-center">Stav</th>
<th class="text-end" data-orderable="false">Akce</th>
</tr>
</thead>
<tbody>
{foreach $uzivatele as $u}
<tr n:class="!$u->AKTIVNI ? 'table-light text-muted'">
<td data-label="E-mail" class="fw-bold">
<a href="mailto:{$u->EMAIL}" class="text-decoration-none">{$u->EMAIL}</a>
</td>
<td data-label="Databáze">{$u->DATABASE}</td>
<td data-label="Firma">
{$u->FIRMA_NAZEV ?: ('#' . $u->FIRMA_ID)}
<span class="small text-muted">(#{$u->FIRMA_ID})</span>
</td>
<td data-label="Role" class="text-center">
{if $u->IS_ADMIN}
<span class="badge bg-dark">Admin</span>
{else}
<span class="badge bg-light text-dark border">User</span>
{/if}
</td>
<td data-label="Poslední přihlášení">
{if $u->POSLEDNI_PRIHLASENI}
{$u->POSLEDNI_PRIHLASENI|date:'d.m.Y H:i'}
{else}
<span class="text-muted">—</span>
{/if}
</td>
<td data-label="Stav" class="text-center">
{if $u->AKTIVNI}
<span class="badge bg-success">Aktivní</span>
{else}
<span class="badge bg-secondary">Neaktivní</span>
{/if}
</td>
<td class="text-end text-nowrap cell-actions">
<button type="button"
class="btn btn-sm btn-outline-primary js-user-edit"
data-login-id="{$u->ID}"
data-email="{$u->EMAIL}"
data-label="{$u->EMAIL}"
title="Upravit e-mail / heslo">
<i class="bi bi-person-gear"></i>
</button>
</td>
</tr>
{/foreach}
</tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="userEditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form n:name="userForm">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-person-gear me-1"></i>Uživatel — <span id="userEditLabel"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zavřít"></button>
</div>
<div class="modal-body">
<input n:name="loginId">
<div class="mb-3">
<label n:name="email" class="form-label">E-mail / přihlašovací jméno</label>
<input n:name="email" class="form-control">
</div>
<div class="mb-3">
<label n:name="password" class="form-label">Nové heslo</label>
<input n:name="password" class="form-control" placeholder="ponechte prázdné = beze změny">
</div>
<div class="mb-0">
<label n:name="passwordVerify" class="form-label">Heslo znovu</label>
<input n:name="passwordVerify" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Zrušit</button>
<button n:name="send" class="btn btn-primary">Uložit</button>
</div>
</form>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/datatables.net@1.13.6/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.6/js/dataTables.bootstrap5.min.js"></script>
{syntax off}
<script>
$(document).ready(function() {
$('#uzivatele-table').DataTable({
language: { url: 'https://cdn.datatables.net/plug-ins/1.13.6/i18n/cs.json' },
order: [[0, 'asc']],
pageLength: 25,
autoWidth: false
});
const userModalEl = document.getElementById('userEditModal');
const userModal = userModalEl ? new bootstrap.Modal(userModalEl) : null;
const $userForm = $('form[id$="-userForm"], #frm-userForm').first();
$(document).on('click', '.js-user-edit', function() {
$userForm.find('[name="loginId"]').val($(this).data('login-id'));
$userForm.find('[name="email"]').val($(this).data('email'));
$userForm.find('[name="password"]').val('');
$userForm.find('[name="passwordVerify"]').val('');
$('#userEditLabel').text($(this).data('label'));
if (userModal) {
userModal.show();
}
});
// Při změně databáze v add form načti firmy přes redirect s ?db=
$('#addUserDatabase').on('change', function() {
const db = $(this).val();
if (db) {
const url = new URL(window.location.href);
url.searchParams.set('db', db);
window.location.href = url.toString();
}
});
});
</script>
{/syntax}

View File

@ -4,6 +4,11 @@ parameters:
user: kufrik
password: 'giteaKufrik_2026!'
# Sdílená tenant DB (zatím jen localhost / jedna databáze)
tenantDb:
server: localhost
database: kufrik
# Nahrané SDF soubory (SQL Server Compact) a heslo staré databáze
sdfStorageDir: %appDir%/../data/sdf
sdfPassword: 'Kmetix2016_'

View File

@ -6,8 +6,15 @@ services:
# Authenticator si vyžádá připojení k Master DB
- App\Model\Security\Authenticator(@database.default.connection)
# Seznam databází (LOGIN) z Master DB
- App\Model\Facade\DatabazeFacade(@database.default.connection, %sdfStorageDir%)
# Firmy / přístupy (LOGIN) ve sdílené tenant DB
- App\Model\Facade\DatabazeFacade(
@database.default.connection,
%sdfStorageDir%,
@security.passwords,
@kufrikConnectionFactory,
%tenantDb.server%,
%tenantDb.database%
)
- App\Model\Sdf\SdfCompactExporter(%sdfExportScript%, %sdfPassword%)
- App\Model\Sdf\SdfKufrikImporter

View File

@ -100,7 +100,7 @@ GO
ALTER DATABASE [kufrik] SET AUTO_CLOSE OFF WITH NO_WAIT;
GO
--user (stejný i pro master DB):
-- user (stejný i pro master DB):
USE [kufrik]
GO
CREATE USER [kufrik] FOR LOGIN [kufrik]
@ -110,6 +110,20 @@ GO
ALTER ROLE [db_owner] ADD MEMBER [kufrik]
GO
-- Metadata: označení datové DB aplikace Kufřík (Admin vypisuje jen takto označené)
-- Property na databázi = sp_addextendedproperty bez level parametrů (v kontextu USE [kufrik])
IF NOT EXISTS (
SELECT 1
FROM sys.extended_properties
WHERE class = 0 AND major_id = 0 AND minor_id = 0 AND name = N'kufrik_app'
)
BEGIN
EXEC sys.sp_addextendedproperty
@name = N'kufrik_app',
@value = N'1';
END
GO
-- ==========================================
-- 1. FIRMA HLAVNÍ TABULKA - vázaná na LOGIN z master_DB (taky bude tenant - izolace dat)
-- ==========================================

View File

@ -0,0 +1,28 @@
-- Označí existující datovou databázi jako Kufřík (extended property).
-- Admin → Databáze vypisuje jen DB s kufrik_app = 1.
-- Property se nastavuje UVNITŘ databáze (bez @level0type = DATABASE).
USE [kufrik]
GO
IF NOT EXISTS (
SELECT 1
FROM sys.extended_properties
WHERE class = 0 AND major_id = 0 AND minor_id = 0 AND name = N'kufrik_app'
)
BEGIN
EXEC sys.sp_addextendedproperty
@name = N'kufrik_app',
@value = N'1';
PRINT N'Databáze [kufrik] byla označena (kufrik_app=1).';
END
ELSE
BEGIN
PRINT N'Databáze [kufrik] už je označená.';
END
GO
SELECT name, CONVERT(nvarchar(100), value) AS value
FROM sys.extended_properties
WHERE class = 0 AND major_id = 0 AND minor_id = 0;
GO