refactor: odstraneni PSR-1 SideEffects warningu
- Handler funkce extrahovany z API souboru do api/admin/handlers/ - config.php rozdeleny na helpers.php (funkce) a constants.php (konstanty) - require_once odstranen z class souboru (AuditLog, JWTAuth, LeaveNotification) - vendor/autoload.php presunuto do config.php bootstrap - totp-handlers.php: pridany use deklarace pro TwoFactorAuth - phpstan.neon: bootstrapFiles, scanDirectories, dynamicConstantNames - Opraveny chybejici routing bloky v totp.php a session.php Vysledek: phpcs 0 errors 0 warnings, PHPStan 0 errors, ESLint 0 errors Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,8 @@
|
|||||||
# Dependencies
|
# Dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
vendor/
|
vendor/
|
||||||
|
example_design/
|
||||||
|
sql/
|
||||||
|
|
||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
149
CLAUDE.md
149
CLAUDE.md
@@ -3,42 +3,120 @@
|
|||||||
## Projektová paměť
|
## Projektová paměť
|
||||||
|
|
||||||
Před začátkem práce si načti relevantní soubory z `memory/`:
|
Před začátkem práce si načti relevantní soubory z `memory/`:
|
||||||
- `MEMORY.md` — tech stack, architektura, klíčové poznámky
|
- `MEMORY.md` - tech stack, architektura, klíčové poznámky
|
||||||
- `structure.md` — adresářová struktura, routes, API endpointy
|
- `structure.md` - adresářová struktura, routes, API endpointy
|
||||||
- `patterns.md` — coding konvence (frontend i backend), CSS architektura
|
- `patterns.md` - coding konvence (frontend i backend), CSS architektura
|
||||||
- `modules.md` — detaily modulů (attendance, offers, orders, invoices, projects, atd.)
|
- `modules.md` - detaily modulů (attendance, offers, orders, invoices, projects, atd.)
|
||||||
- `design-system.md` — CSS třídy, komponenty, theme tokens
|
- `design-system.md` - CSS třídy, komponenty, theme tokens
|
||||||
- `security.md` — bezpečnostní audit, auth, rate limiting
|
- `security.md` - bezpečnostní audit, auth, rate limiting
|
||||||
|
|
||||||
Čti podle typu úkolu — nemusíš vše, ale vždy alespoň `MEMORY.md`.
|
Čti podle typu úkolu - nemusíš vše, ale vždy alespoň `MEMORY.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prostředí
|
## Prostředí
|
||||||
|
|
||||||
- **Dev server:** Apache WAMP → `C:\Apache24\htdocs`
|
- **Projekt:** `D:\Claude\BOHA Website\app`
|
||||||
- **PHP/MySQL:** v systémovém PATH (`php -v`, `mysql -u root`)
|
- **Frontend:** Vite dev server (`npm run dev`) - port 3000
|
||||||
- **npm:** při instalaci balíčků vždy `--legacy-peer-deps` (eslint peer conflict)
|
- **Backend:** PHP built-in server (`php -S localhost:8000`) - port 8000
|
||||||
- **Deploy (dev):** po každém `npm run build` smaž obsah `C:\Apache24\htdocs` a zkopíruj tam `dist/`
|
- **Databáze:** MySQL 8.4 na localhost, DB název: `app`
|
||||||
- **DB migrace:** SQL soubory ukládej do `sql/`, aplikuj přes `mysql` příkaz
|
- **Vite proxy:** `/api` -> `http://localhost:8000`
|
||||||
|
- **npm:** při instalaci balíčků vždy `--legacy-peer-deps`
|
||||||
|
- **Git remote:** https://git.boha-automation.cz/boha_admin/app.git
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spuštění dev serveru
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 - Frontend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Terminal 2 - PHP API
|
||||||
|
php -S localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Aplikace běží na http://localhost:3000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autonomní režim
|
||||||
|
|
||||||
|
Když běžíš s `--auto`, dodržuj tento postup:
|
||||||
|
|
||||||
|
### Před jakoukoli prací:
|
||||||
|
1. Načti `memory/MEMORY.md` a relevantní memory soubory
|
||||||
|
2. Spusť diagnostiku:
|
||||||
|
- `npx eslint src/` - zkontroluj chyby
|
||||||
|
- `vendor/bin/phpcs` - zkontroluj PSR-12
|
||||||
|
- `npm run build` - ověř že build projde
|
||||||
|
|
||||||
|
### Během práce:
|
||||||
|
- Po každé větší změně spusť `npm run build` - ověř že nic nerozbíjíš
|
||||||
|
- Opravuj průběžně ESLint a PHPCS chyby
|
||||||
|
- Pokud si nejsi jistý architektonickým rozhodnutím, zapiš do reportu a pokračuj dál
|
||||||
|
|
||||||
|
### Po dokončení práce:
|
||||||
|
1. Spusť finální kontroly:
|
||||||
|
- `npx eslint src/` - 0 errors
|
||||||
|
- `vendor/bin/phpcs` - 0 errors
|
||||||
|
- `npm run build` - musí projít
|
||||||
|
2. Commitni a pushni:
|
||||||
|
- `git add .`
|
||||||
|
- `git commit -m "typ: stručný popis změn"`
|
||||||
|
- `git push`
|
||||||
|
3. Napiš report do `reports/YYYY-MM-DD.md`
|
||||||
|
|
||||||
|
### Commit message konvence:
|
||||||
|
- `feat:` - nová funkce
|
||||||
|
- `fix:` - oprava bugu
|
||||||
|
- `refactor:` - refaktoring bez změny funkčnosti
|
||||||
|
- `style:` - formátování, lint opravy
|
||||||
|
- `docs:` - dokumentace
|
||||||
|
- `perf:` - optimalizace výkonu
|
||||||
|
|
||||||
|
### Report formát:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Report YYYY-MM-DD
|
||||||
|
|
||||||
|
## Zadání
|
||||||
|
- Co bylo požadováno
|
||||||
|
|
||||||
|
## Provedené změny
|
||||||
|
- Seznam změn s krátkým popisem
|
||||||
|
|
||||||
|
## Nalezené problémy
|
||||||
|
- Problémy co vyžadují rozhodnutí uživatele (pokud nějaké)
|
||||||
|
|
||||||
|
## Stav
|
||||||
|
- ESLint: PASS/FAIL
|
||||||
|
- PHPCS: PASS/FAIL
|
||||||
|
- Build: PASS/FAIL
|
||||||
|
- Git: commitnuto a pushnuto / jen commitnuto / nepushnuto
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
### PHP
|
### PHP
|
||||||
- PSR-12, `strict_types=1` vždy
|
- PSR-12, `strict_types=1` vždy
|
||||||
- Typované parametry a návratové typy povinné
|
- Typované parametry a návratové typy povinné
|
||||||
- Repository pattern pro DB — žádné raw SQL v controllerech
|
- Repository pattern pro DB - žádné raw SQL v controllerech
|
||||||
- Prepared statements s `?` placeholdery, žádná string interpolace v SQL
|
- Prepared statements s `?` placeholdery, žádná string interpolace v SQL
|
||||||
- PHPStan level 6 musí projít
|
- PHPStan level 6 musí projít
|
||||||
- `vendor/bin/phpcs` (PSR-12) musí projít bez chyb
|
- `vendor/bin/phpcs` (PSR-12) musí projít bez chyb
|
||||||
- Docblock PŘED `declare(strict_types=1)`, ne za ním
|
- Docblock PŘED `declare(strict_types=1)`, ne za ním
|
||||||
|
|
||||||
### React
|
### React
|
||||||
- Funkcionální komponenty, žádné class components (výjimka: `ErrorBoundary.jsx` - React nemá hook pro error boundaries)
|
- Funkcionální komponenty, žádné class components (výjimka: `ErrorBoundary.jsx`)
|
||||||
- Props typované přes interface, ne inline type
|
- Props typované přes interface, ne inline type
|
||||||
- Žádné `any` typy
|
- Žádné `any` typy
|
||||||
- Custom hooks pro business logiku — ne inline useEffect spaghetti
|
- Custom hooks pro business logiku - ne inline useEffect spaghetti
|
||||||
- Žádné vnořené ternáry — použít early return, if/else, helper funkci nebo lookup objekt
|
- Žádné vnořené ternáry - použít early return, if/else, helper funkci nebo lookup objekt
|
||||||
- Striktní porovnání (`===`/`!==`), nikdy `==`/`!=`
|
- Striktní porovnání (`===`/`!==`), nikdy `==`/`!=`
|
||||||
- API volání vždy přes `apiFetch()` z `../utils/api` — nikdy raw `fetch` (výjimka: `AuthContext.jsx` - auth vrstva používá raw `fetch`, protože `apiFetch` na ní závisí)
|
- API volání vždy přes `apiFetch()` z `../utils/api` (výjimka: `AuthContext.jsx`)
|
||||||
|
|
||||||
### Data konvence
|
### Data konvence
|
||||||
- Všechny response klíče **snake_case** (DB sloupce, PHP proměnné, JSON klíče)
|
- Všechny response klíče **snake_case** (DB sloupce, PHP proměnné, JSON klíče)
|
||||||
@@ -51,38 +129,47 @@ Před začátkem práce si načti relevantní soubory z `memory/`:
|
|||||||
- API error: `{ "success": false, "error": "message" }`
|
- API error: `{ "success": false, "error": "message" }`
|
||||||
- PHP: `jsonResponse()`, `errorResponse()`, `successResponse()` helpery
|
- PHP: `jsonResponse()`, `errorResponse()`, `successResponse()` helpery
|
||||||
- Frontend: `alert.success()` / `alert.error()` přes `useAlert()`
|
- Frontend: `alert.success()` / `alert.error()` přes `useAlert()`
|
||||||
- Formulářová validace vždy frontend inline — viz sekce níže
|
- Formulářová validace vždy frontend inline
|
||||||
|
|
||||||
### Inline validace formulářů
|
### Inline validace formulářů
|
||||||
- `has-error` class na `.admin-form-group` + `.admin-form-error` pod inputem
|
- `has-error` class na `.admin-form-group` + `.admin-form-error` pod inputem
|
||||||
- Error message vždy **za inputem** (ne před ním — posouvá layout)
|
- Error message vždy **za inputem** (ne před ním)
|
||||||
- `const [errors, setErrors] = useState({})` — validace v handleSubmit/handleSave, clear na onChange
|
- `const [errors, setErrors] = useState({})` - validace v handleSubmit/handleSave, clear na onChange
|
||||||
- Povinná pole: `required` class na labelu (červená hvězdička)
|
- Povinná pole: `required` class na labelu (červená hvězdička)
|
||||||
- Detaily viz `design-system.md`
|
- Detaily viz `design-system.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Komentáře v kódu
|
## Komentáře v kódu
|
||||||
|
|
||||||
- Piš jako programátor, stručně a k věci
|
- Piš jako programátor, stručně a k věci
|
||||||
- Nekomentuj zřejmé věci (`setUser(null)`, `getAccessToken()`)
|
- Nekomentuj zřejmé věci
|
||||||
- Nepoužívej em dash (—), používej normální pomlčku (-)
|
- Nepoužívej em dash, používej normální pomlčku (-)
|
||||||
- Čeština i angličtina OK, preferuj češtinu u nových komentářů
|
- Čeština i angličtina OK, preferuj češtinu u nových komentářů
|
||||||
- Komentář jen když přidává info, které nejde vyčíst z kódu (důvod, workaround, gotcha)
|
- Komentář jen když přidává info, které nejde vyčíst z kódu
|
||||||
- Docblocky jen u složitějších funkcí/API endpointů
|
- Docblocky jen u složitějších funkcí/API endpointů
|
||||||
- Žádné komentáře typu `// Set headers`, `// Get input`
|
- Žádné komentáře typu `// Set headers`, `// Get input`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Kontroly před buildem
|
## Kontroly před buildem
|
||||||
|
|
||||||
Vždy spusť před `npm run build`:
|
Vždy spusť před `npm run build`:
|
||||||
|
|
||||||
1. `npx eslint src/` — 0 errors, 0 warnings
|
1. `npx eslint src/` - 0 errors, 0 warnings
|
||||||
2. `vendor/bin/phpcs` — 0 errors (warnings tolerovány, ale nepotlačuj je v nastavení)
|
2. `vendor/bin/phpcs` - 0 errors
|
||||||
3. Build musí projít bez chyb
|
3. Build musí projít bez chyb
|
||||||
|
|
||||||
## Pravidla
|
---
|
||||||
|
|
||||||
- Nikdy nečti `.env` soubory
|
## Zakázané operace
|
||||||
- Chrome používej pouze na výslovnou žádost uživatele
|
|
||||||
|
- NIKDY nečti `.env` soubory
|
||||||
|
- NIKDY nepoužívej SSH, SCP, rsync na jakýkoli vzdálený server
|
||||||
|
- NIKDY neměň databázi na vzdáleném serveru
|
||||||
|
- NIKDY nespouštěj Chrome bez výslovné žádosti uživatele
|
||||||
- Žádné TODO/FIXME v kódu
|
- Žádné TODO/FIXME v kódu
|
||||||
- Žádné `console.log` v kódu — ani po debugování (console.error jen s `import.meta.env.DEV` guardem)
|
- Žádné `console.log` v kódu (console.error jen s `import.meta.env.DEV` guardem)
|
||||||
- Funkce max 50 řádků — u React komponent se počítá logika, ne JSX template
|
- Funkce max 50 řádků - u React komponent se počítá logika, ne JSX template
|
||||||
- Permissions: frontend `hasPermission()` + `<Forbidden />` guard, backend `requirePermission()`
|
- Žádné `any` typy v TypeScript/React
|
||||||
|
- Žádná string interpolace v SQL dotazech
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/CzechHolidays.php';
|
require_once dirname(__DIR__) . '/includes/CzechHolidays.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AttendanceAdmin.php';
|
require_once dirname(__DIR__) . '/includes/AttendanceAdmin.php';
|
||||||
|
require_once __DIR__ . '/handlers/attendance-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -149,589 +150,3 @@ try {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// User-facing handlers
|
// User-facing handlers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function handleGetCurrent(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$today = date('Y-m-d');
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT * FROM attendance
|
|
||||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$ongoingShift = $stmt->fetch();
|
|
||||||
|
|
||||||
$projectLogs = [];
|
|
||||||
$activeProjectId = null;
|
|
||||||
if ($ongoingShift) {
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
|
||||||
$stmt->execute([$ongoingShift['id']]);
|
|
||||||
$projectLogs = $stmt->fetchAll();
|
|
||||||
foreach ($projectLogs as $log) {
|
|
||||||
if ($log['ended_at'] === null) {
|
|
||||||
$activeProjectId = (int)$log['project_id'];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT * FROM attendance
|
|
||||||
WHERE user_id = ? AND shift_date = ?
|
|
||||||
AND departure_time IS NOT NULL
|
|
||||||
AND (leave_type IS NULL OR leave_type = 'work')
|
|
||||||
ORDER BY arrival_time DESC
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId, $today]);
|
|
||||||
$todayShifts = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$completedShiftIds = array_column($todayShifts, 'id');
|
|
||||||
$completedProjectLogs = [];
|
|
||||||
if (!empty($completedShiftIds)) {
|
|
||||||
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
"SELECT * FROM attendance_project_logs
|
|
||||||
WHERE attendance_id IN ($placeholders)
|
|
||||||
ORDER BY started_at ASC"
|
|
||||||
);
|
|
||||||
$stmt->execute($completedShiftIds);
|
|
||||||
$allLogs = $stmt->fetchAll();
|
|
||||||
foreach ($allLogs as $log) {
|
|
||||||
$completedProjectLogs[$log['attendance_id']][] = $log;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$leaveBalance = getLeaveBalance($pdo, $userId);
|
|
||||||
|
|
||||||
$currentYear = (int)date('Y');
|
|
||||||
$currentMonth = (int)date('m');
|
|
||||||
$fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth);
|
|
||||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth);
|
|
||||||
|
|
||||||
$startDate = date('Y-m-01');
|
|
||||||
$endDate = date('Y-m-t');
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM attendance
|
|
||||||
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId, $startDate, $endDate]);
|
|
||||||
$monthRecords = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$workedMinutes = 0;
|
|
||||||
$leaveHoursMonth = 0;
|
|
||||||
$vacationHours = 0;
|
|
||||||
$sickHours = 0;
|
|
||||||
$holidayHours = 0;
|
|
||||||
$unpaidHours = 0;
|
|
||||||
foreach ($monthRecords as $rec) {
|
|
||||||
$lt = $rec['leave_type'] ?? 'work';
|
|
||||||
$lh = (float)($rec['leave_hours'] ?? 0);
|
|
||||||
if ($lt === 'work') {
|
|
||||||
if ($rec['departure_time']) {
|
|
||||||
$workedMinutes += calculateWorkMinutes($rec);
|
|
||||||
}
|
|
||||||
} elseif ($lt === 'vacation') {
|
|
||||||
$vacationHours += $lh;
|
|
||||||
$leaveHoursMonth += $lh;
|
|
||||||
} elseif ($lt === 'sick') {
|
|
||||||
$sickHours += $lh;
|
|
||||||
$leaveHoursMonth += $lh;
|
|
||||||
} elseif ($lt === 'holiday') {
|
|
||||||
$holidayHours += $lh;
|
|
||||||
} elseif ($lt === 'unpaid') {
|
|
||||||
$unpaidHours += $lh;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$workedHours = round($workedMinutes / 60, 1);
|
|
||||||
$covered = $workedHours + $leaveHoursMonth;
|
|
||||||
$remaining = max(0, $fund - $covered);
|
|
||||||
$overtime = max(0, round($covered - $fund, 1));
|
|
||||||
|
|
||||||
$monthlyFund = [
|
|
||||||
'fund' => $fund,
|
|
||||||
'business_days' => $businessDays,
|
|
||||||
'worked' => $workedHours,
|
|
||||||
'leave_hours' => $leaveHoursMonth,
|
|
||||||
'vacation_hours' => $vacationHours,
|
|
||||||
'sick_hours' => $sickHours,
|
|
||||||
'holiday_hours' => $holidayHours,
|
|
||||||
'unpaid_hours' => $unpaidHours,
|
|
||||||
'covered' => $covered,
|
|
||||||
'remaining' => $remaining,
|
|
||||||
'overtime' => $overtime,
|
|
||||||
'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Enrich project logs with names
|
|
||||||
$allLogProjectIds = [];
|
|
||||||
foreach ($projectLogs as $l) {
|
|
||||||
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
|
||||||
}
|
|
||||||
foreach ($completedProjectLogs as $logs) {
|
|
||||||
foreach ($logs as $l) {
|
|
||||||
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$projNameMap = fetchProjectNames($allLogProjectIds);
|
|
||||||
|
|
||||||
foreach ($projectLogs as &$l) {
|
|
||||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
|
||||||
}
|
|
||||||
unset($l);
|
|
||||||
foreach ($completedProjectLogs as &$logs) {
|
|
||||||
foreach ($logs as &$l) {
|
|
||||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
|
||||||
}
|
|
||||||
unset($l);
|
|
||||||
}
|
|
||||||
unset($logs);
|
|
||||||
|
|
||||||
foreach ($todayShifts as &$shift) {
|
|
||||||
$shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? [];
|
|
||||||
}
|
|
||||||
unset($shift);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'ongoing_shift' => $ongoingShift,
|
|
||||||
'today_shifts' => $todayShifts,
|
|
||||||
'date' => $today,
|
|
||||||
'leave_balance' => $leaveBalance,
|
|
||||||
'monthly_fund' => $monthlyFund,
|
|
||||||
'project_logs' => $projectLogs,
|
|
||||||
'active_project_id' => $activeProjectId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetHistory(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$month = validateMonth();
|
|
||||||
$year = (int)substr($month, 0, 4);
|
|
||||||
$monthNum = (int)substr($month, 5, 2);
|
|
||||||
|
|
||||||
$startDate = "{$month}-01";
|
|
||||||
$endDate = date('Y-m-t', strtotime($startDate));
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM attendance
|
|
||||||
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
|
||||||
ORDER BY shift_date DESC
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId, $startDate, $endDate]);
|
|
||||||
$records = $stmt->fetchAll();
|
|
||||||
|
|
||||||
enrichRecordsWithProjectLogs($pdo, $records);
|
|
||||||
|
|
||||||
$totalMinutes = 0;
|
|
||||||
$vacationHours = 0;
|
|
||||||
$sickHours = 0;
|
|
||||||
$holidayHours = 0;
|
|
||||||
$unpaidHours = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
|
||||||
$leaveType = $record['leave_type'] ?? 'work';
|
|
||||||
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
|
||||||
|
|
||||||
if ($leaveType === 'vacation') {
|
|
||||||
$vacationHours += $leaveHours;
|
|
||||||
} elseif ($leaveType === 'sick') {
|
|
||||||
$sickHours += $leaveHours;
|
|
||||||
} elseif ($leaveType === 'holiday') {
|
|
||||||
$holidayHours += $leaveHours;
|
|
||||||
} elseif ($leaveType === 'unpaid') {
|
|
||||||
$unpaidHours += $leaveHours;
|
|
||||||
} else {
|
|
||||||
$totalMinutes += calculateWorkMinutes($record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
|
||||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
|
||||||
$workedHours = round($totalMinutes / 60, 1);
|
|
||||||
$leaveHoursCovered = $vacationHours + $sickHours;
|
|
||||||
$covered = $workedHours + $leaveHoursCovered;
|
|
||||||
$remaining = max(0, round($fund - $covered, 1));
|
|
||||||
$overtime = max(0, round($covered - $fund, 1));
|
|
||||||
|
|
||||||
$leaveBalance = getLeaveBalance($pdo, $userId, $year);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'records' => $records,
|
|
||||||
'month' => $month,
|
|
||||||
'year' => $year,
|
|
||||||
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
|
||||||
'total_minutes' => $totalMinutes,
|
|
||||||
'vacation_hours' => $vacationHours,
|
|
||||||
'sick_hours' => $sickHours,
|
|
||||||
'holiday_hours' => $holidayHours,
|
|
||||||
'unpaid_hours' => $unpaidHours,
|
|
||||||
'leave_balance' => $leaveBalance,
|
|
||||||
'monthly_fund' => [
|
|
||||||
'fund' => $fund,
|
|
||||||
'business_days' => $businessDays,
|
|
||||||
'worked' => $workedHours,
|
|
||||||
'leave_hours' => $leaveHoursCovered,
|
|
||||||
'covered' => $covered,
|
|
||||||
'remaining' => $remaining,
|
|
||||||
'overtime' => $overtime,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePunch(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$action = $input['punch_action'] ?? '';
|
|
||||||
$today = date('Y-m-d');
|
|
||||||
$rawNow = date('Y-m-d H:i:s');
|
|
||||||
|
|
||||||
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
|
|
||||||
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
|
|
||||||
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
|
|
||||||
$address = !empty($input['address']) ? $input['address'] : null;
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT * FROM attendance
|
|
||||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$ongoingShift = $stmt->fetch();
|
|
||||||
|
|
||||||
if ($action === 'arrival' && !$ongoingShift) {
|
|
||||||
$now = roundUpTo15Minutes($rawNow);
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO attendance
|
|
||||||
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
|
|
||||||
|
|
||||||
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
|
||||||
'arrival_time' => $now,
|
|
||||||
'location' => $address,
|
|
||||||
], 'Příchod zaznamenán');
|
|
||||||
|
|
||||||
successResponse(null, 'Příchod zaznamenán');
|
|
||||||
} elseif ($ongoingShift) {
|
|
||||||
switch ($action) {
|
|
||||||
case 'break_start':
|
|
||||||
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
|
|
||||||
$breakStart = roundToNearest10Minutes($rawNow);
|
|
||||||
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
|
||||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
|
||||||
|
|
||||||
successResponse(null, 'Pauza zaznamenána');
|
|
||||||
} else {
|
|
||||||
errorResponse('Nelze zadat pauzu');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'departure':
|
|
||||||
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
|
|
||||||
$now = roundDownTo15Minutes($rawNow);
|
|
||||||
|
|
||||||
// Auto-add break if shift is longer than 6h and no break
|
|
||||||
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
|
|
||||||
$arrivalTime = strtotime($ongoingShift['arrival_time']);
|
|
||||||
$departureTime = strtotime($now);
|
|
||||||
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
|
|
||||||
|
|
||||||
if ($hoursWorked > 12) {
|
|
||||||
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
|
||||||
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
|
|
||||||
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
|
||||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
|
||||||
} elseif ($hoursWorked > 6) {
|
|
||||||
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
|
||||||
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
|
|
||||||
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
|
||||||
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE attendance
|
|
||||||
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
|
|
||||||
departure_accuracy = ?, departure_address = ?
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
|
|
||||||
|
|
||||||
// Close any open project log
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
|
|
||||||
');
|
|
||||||
$stmt->execute([$now, $ongoingShift['id']]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
|
|
||||||
'departure_time' => $now,
|
|
||||||
'location' => $address,
|
|
||||||
], 'Odchod zaznamenán');
|
|
||||||
|
|
||||||
successResponse(null, 'Odchod zaznamenán');
|
|
||||||
} else {
|
|
||||||
errorResponse('Nelze zadat odchod');
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
errorResponse('Neplatná akce');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errorResponse('Neplatná akce - nemáte aktivní směnu');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateAddress(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$address = trim($input['address'] ?? '');
|
|
||||||
$punchAction = $input['punch_action'] ?? '';
|
|
||||||
|
|
||||||
if (!$address) {
|
|
||||||
successResponse(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($punchAction === 'arrival') {
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
UPDATE attendance SET arrival_address = ?
|
|
||||||
WHERE id = (
|
|
||||||
SELECT id FROM (
|
|
||||||
SELECT id FROM attendance
|
|
||||||
WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '')
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
) t
|
|
||||||
)
|
|
||||||
");
|
|
||||||
} else {
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
UPDATE attendance SET departure_address = ?
|
|
||||||
WHERE id = (
|
|
||||||
SELECT id FROM (
|
|
||||||
SELECT id FROM attendance
|
|
||||||
WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '')
|
|
||||||
AND departure_time IS NOT NULL
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
) t
|
|
||||||
)
|
|
||||||
");
|
|
||||||
}
|
|
||||||
$stmt->execute([$address, $userId]);
|
|
||||||
|
|
||||||
successResponse(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddLeave(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$leaveType = $input['leave_type'] ?? '';
|
|
||||||
$leaveDate = $input['leave_date'] ?? '';
|
|
||||||
$leaveHours = (float)($input['leave_hours'] ?? 8);
|
|
||||||
$notes = trim($input['notes'] ?? '');
|
|
||||||
|
|
||||||
if (!$leaveType || !$leaveDate || $leaveHours <= 0) {
|
|
||||||
errorResponse('Vyplňte všechna povinná pole');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
|
||||||
errorResponse('Neplatný typ nepřítomnosti');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($leaveType === 'vacation') {
|
|
||||||
$year = (int)date('Y', strtotime($leaveDate));
|
|
||||||
$balance = getLeaveBalance($pdo, $userId, $year);
|
|
||||||
|
|
||||||
if ($balance['vacation_remaining'] < $leaveHours) {
|
|
||||||
errorResponse(
|
|
||||||
"Nemáte dostatek hodin dovolené. Zbývá vám "
|
|
||||||
. "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]);
|
|
||||||
|
|
||||||
updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours);
|
|
||||||
|
|
||||||
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
|
||||||
'leave_type' => $leaveType,
|
|
||||||
'leave_hours' => $leaveHours,
|
|
||||||
], "Zaznamenána nepřítomnost: $leaveType");
|
|
||||||
|
|
||||||
successResponse(null, 'Nepřítomnost byla zaznamenána');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveNotes(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$notes = trim($input['notes'] ?? '');
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT id FROM attendance
|
|
||||||
WHERE user_id = ? AND departure_time IS NULL
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$currentShift = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$currentShift) {
|
|
||||||
errorResponse('Nemáte aktivní směnu');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?');
|
|
||||||
$stmt->execute([$notes, $currentShift['id']]);
|
|
||||||
|
|
||||||
successResponse(null, 'Poznámka byla uložena');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetProjects(): void
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$pdo = db();
|
|
||||||
$stmt = $pdo->query(
|
|
||||||
"SELECT id, project_number, name FROM projects
|
|
||||||
WHERE status = 'aktivni' ORDER BY project_number ASC"
|
|
||||||
);
|
|
||||||
$projects = $stmt->fetchAll();
|
|
||||||
successResponse(['projects' => $projects]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
error_log('Failed to fetch projects: ' . $e->getMessage());
|
|
||||||
successResponse(['projects' => []]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSwitchProject(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
/** @var mixed $rawProjectId */
|
|
||||||
$rawProjectId = $input['project_id'] ?? null;
|
|
||||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
|
||||||
? (int)$rawProjectId
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT id FROM attendance
|
|
||||||
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$currentShift = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$currentShift) {
|
|
||||||
errorResponse('Nemáte aktivní směnu');
|
|
||||||
}
|
|
||||||
|
|
||||||
$attendanceId = $currentShift['id'];
|
|
||||||
$now = date('Y-m-d H:i:s');
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'UPDATE attendance_project_logs SET ended_at = ?
|
|
||||||
WHERE attendance_id = ? AND ended_at IS NULL'
|
|
||||||
);
|
|
||||||
$stmt->execute([$now, $attendanceId]);
|
|
||||||
|
|
||||||
if ($projectId) {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'INSERT INTO attendance_project_logs
|
|
||||||
(attendance_id, project_id, started_at) VALUES (?, ?, ?)'
|
|
||||||
);
|
|
||||||
$stmt->execute([$attendanceId, $projectId, $now]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE attendance SET project_id = ? WHERE id = ?');
|
|
||||||
$stmt->execute([$projectId, $attendanceId]);
|
|
||||||
|
|
||||||
successResponse(null, $projectId ? 'Projekt přepnut' : 'Projekt zastaven');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $authData */
|
|
||||||
function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): void
|
|
||||||
{
|
|
||||||
$attendanceId = (int)($_GET['attendance_id'] ?? 0);
|
|
||||||
if (!$attendanceId) {
|
|
||||||
errorResponse('attendance_id je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ověření vlastnictví záznamu nebo admin oprávnění
|
|
||||||
if (!hasPermission($authData, 'attendance.admin')) {
|
|
||||||
$ownerStmt = $pdo->prepare('SELECT user_id FROM attendance WHERE id = ?');
|
|
||||||
$ownerStmt->execute([$attendanceId]);
|
|
||||||
$owner = $ownerStmt->fetch();
|
|
||||||
if (!$owner || (int)$owner['user_id'] !== $currentUserId) {
|
|
||||||
errorResponse('Nemáte oprávnění zobrazit tyto záznamy', 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
|
||||||
$stmt->execute([$attendanceId]);
|
|
||||||
$logs = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$projectIds = [];
|
|
||||||
foreach ($logs as $l) {
|
|
||||||
$projectIds[$l['project_id']] = $l['project_id'];
|
|
||||||
}
|
|
||||||
$projNameMap = fetchProjectNames($projectIds);
|
|
||||||
foreach ($logs as &$l) {
|
|
||||||
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
|
||||||
}
|
|
||||||
unset($l);
|
|
||||||
|
|
||||||
successResponse(['logs' => $logs]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveProjectLogs(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$attendanceId = (int)($input['attendance_id'] ?? 0);
|
|
||||||
$logs = $input['project_logs'] ?? [];
|
|
||||||
|
|
||||||
if (!$attendanceId) {
|
|
||||||
errorResponse('attendance_id je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
|
||||||
$stmt->execute([$attendanceId]);
|
|
||||||
$record = $stmt->fetch();
|
|
||||||
if (!$record) {
|
|
||||||
errorResponse('Záznam nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
|
||||||
$stmt->execute([$attendanceId]);
|
|
||||||
|
|
||||||
if (!empty($logs)) {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'INSERT INTO attendance_project_logs
|
|
||||||
(attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)'
|
|
||||||
);
|
|
||||||
foreach ($logs as $log) {
|
|
||||||
$projectId = (int)($log['project_id'] ?? 0);
|
|
||||||
if (!$projectId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$h = (int)($log['hours'] ?? 0);
|
|
||||||
$m = (int)($log['minutes'] ?? 0);
|
|
||||||
if ($h === 0 && $m === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$stmt->execute([$attendanceId, $projectId, $h, $m]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse(null, 'Projektové záznamy byly uloženy');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/bank-accounts-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -67,166 +68,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGetBankAccountList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('SELECT * FROM bank_accounts ORDER BY position, id');
|
|
||||||
successResponse($stmt->fetchAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateBankAccount(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$accountName = trim($input['account_name'] ?? '');
|
|
||||||
$bankName = trim($input['bank_name'] ?? '');
|
|
||||||
$accountNumber = trim($input['account_number'] ?? '');
|
|
||||||
$iban = trim($input['iban'] ?? '');
|
|
||||||
$bic = trim($input['bic'] ?? '');
|
|
||||||
$currency = trim($input['currency'] ?? 'CZK');
|
|
||||||
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
|
||||||
|
|
||||||
if (!$accountName) {
|
|
||||||
errorResponse('Název účtu je povinný');
|
|
||||||
}
|
|
||||||
if (mb_strlen($accountName) > 100) {
|
|
||||||
errorResponse('Název účtu je příliš dlouhý (max 100 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bankName) > 255) {
|
|
||||||
errorResponse('Název banky je příliš dlouhý (max 255 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($accountNumber) > 50) {
|
|
||||||
errorResponse('Číslo účtu je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($iban) > 50) {
|
|
||||||
errorResponse('IBAN je příliš dlouhý (max 50 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bic) > 20) {
|
|
||||||
errorResponse('BIC/SWIFT je příliš dlouhý (max 20 znaků)');
|
|
||||||
}
|
|
||||||
if (!in_array($currency, ['CZK', 'EUR', 'USD', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zjistit dalsi pozici
|
|
||||||
$maxPos = (int) $pdo->query('SELECT COALESCE(MAX(position), 0) FROM bank_accounts')->fetchColumn();
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
// Pokud je default, zrusit ostatnim
|
|
||||||
if ($isDefault) {
|
|
||||||
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO bank_accounts
|
|
||||||
(account_name, bank_name, account_number, iban, bic, currency, is_default, position)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$accountName, $bankName, $accountNumber, $iban, $bic, $currency, $isDefault, $maxPos + 1]);
|
|
||||||
$newId = (int) $pdo->lastInsertId();
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logCreate(
|
|
||||||
'bank_account',
|
|
||||||
$newId,
|
|
||||||
['account_name' => $accountName],
|
|
||||||
"Vytvořen bankovní účet '$accountName'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(['id' => $newId], 'Bankovní účet byl vytvořen');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateBankAccount(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$account = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$account) {
|
|
||||||
errorResponse('Bankovní účet nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
// Delkove limity a validace
|
|
||||||
$maxLengths = ['account_name' => 100, 'bank_name' => 255, 'account_number' => 50, 'iban' => 50, 'bic' => 20];
|
|
||||||
foreach ($maxLengths as $f => $max) {
|
|
||||||
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
|
||||||
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = ['account_name', 'bank_name', 'account_number', 'iban', 'bic', 'currency'];
|
|
||||||
$updates = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
foreach ($fields as $field) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$updates[] = "$field = ?";
|
|
||||||
$params[] = trim((string) $input[$field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
if (array_key_exists('is_default', $input)) {
|
|
||||||
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
|
||||||
if ($isDefault) {
|
|
||||||
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
|
||||||
}
|
|
||||||
$updates[] = 'is_default = ?';
|
|
||||||
$params[] = $isDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($updates)) {
|
|
||||||
errorResponse('Žádná data k aktualizaci');
|
|
||||||
}
|
|
||||||
|
|
||||||
$updates[] = 'modified_at = NOW()';
|
|
||||||
$params[] = $id;
|
|
||||||
|
|
||||||
$sql = 'UPDATE bank_accounts SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate('bank_account', $id, [], $input, "Aktualizován bankovní účet #{$id}");
|
|
||||||
|
|
||||||
successResponse(null, 'Bankovní účet byl aktualizován');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteBankAccount(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$account = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$account) {
|
|
||||||
errorResponse('Bankovní účet nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->prepare('DELETE FROM bank_accounts WHERE id = ?')->execute([$id]);
|
|
||||||
|
|
||||||
AuditLog::logDelete(
|
|
||||||
'bank_account',
|
|
||||||
$id,
|
|
||||||
['account_name' => $account['account_name']],
|
|
||||||
"Smazán bankovní účet '{$account['account_name']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Bankovní účet byl smazán');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/company-settings-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -68,247 +69,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param bool $includeLogo false = bez logo_data BLOBu
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array
|
|
||||||
{
|
|
||||||
if ($includeLogo) {
|
|
||||||
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1');
|
|
||||||
} else {
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT id, company_name, company_id, vat_id, street, city, postal_code, country,
|
|
||||||
quotation_prefix, default_currency, default_vat_rate,
|
|
||||||
custom_fields, uuid, modified_at, sync_version,
|
|
||||||
order_type_code, invoice_type_code, is_deleted,
|
|
||||||
CASE WHEN logo_data IS NOT NULL AND LENGTH(logo_data) > 0 THEN 1 ELSE 0 END as has_logo
|
|
||||||
FROM company_settings LIMIT 1
|
|
||||||
');
|
|
||||||
}
|
|
||||||
$settings = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$settings) {
|
|
||||||
$uuid = sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0x0fff) | 0x4000,
|
|
||||||
random_int(0, 0x3fff) | 0x8000,
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff)
|
|
||||||
);
|
|
||||||
$pdo->prepare(
|
|
||||||
"INSERT INTO company_settings
|
|
||||||
(id, company_name, quotation_prefix, default_currency,
|
|
||||||
default_vat_rate, uuid, modified_at, sync_version)
|
|
||||||
VALUES (1, '', 'N', 'EUR', 21.0, ?, NOW(), 1)"
|
|
||||||
)->execute([$uuid]);
|
|
||||||
return getOrCreateSettings($pdo, $includeLogo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetOffersSettings(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$settings = getOrCreateSettings($pdo, false);
|
|
||||||
/** @var array<mixed>|null $cfRaw */
|
|
||||||
$cfRaw = !empty($settings['custom_fields'])
|
|
||||||
? json_decode($settings['custom_fields'], true)
|
|
||||||
: null;
|
|
||||||
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
|
||||||
$settings['custom_fields'] = $cfRaw;
|
|
||||||
$settings['supplier_field_order'] = null;
|
|
||||||
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
|
||||||
$settings['custom_fields'] = $cfRaw['fields'];
|
|
||||||
$settings['supplier_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
|
||||||
} else {
|
|
||||||
$settings['custom_fields'] = [];
|
|
||||||
$settings['supplier_field_order'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings['has_logo'] = (bool)($settings['has_logo'] ?? false);
|
|
||||||
|
|
||||||
successResponse($settings);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateOffersSettings(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$settings = getOrCreateSettings($pdo);
|
|
||||||
|
|
||||||
// Delkove limity
|
|
||||||
$maxLengths = [
|
|
||||||
'company_name' => 255, 'street' => 255, 'city' => 255,
|
|
||||||
'postal_code' => 20, 'country' => 100,
|
|
||||||
'company_id' => 50, 'vat_id' => 50,
|
|
||||||
'default_currency' => 5,
|
|
||||||
];
|
|
||||||
foreach ($maxLengths as $f => $max) {
|
|
||||||
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
|
||||||
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validace meny
|
|
||||||
if (isset($input['default_currency']) && !in_array($input['default_currency'], ['EUR', 'USD', 'CZK', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = [
|
|
||||||
'company_name', 'street', 'city', 'postal_code', 'country',
|
|
||||||
'company_id', 'vat_id',
|
|
||||||
'quotation_prefix', 'default_currency',
|
|
||||||
'order_type_code', 'invoice_type_code',
|
|
||||||
];
|
|
||||||
|
|
||||||
$setClauses = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
foreach ($fields as $field) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$setClauses[] = "$field = ?";
|
|
||||||
$params[] = $input[$field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// custom_fields + SupplierFieldOrder - ulozeny dohromady jako JSON
|
|
||||||
if (array_key_exists('custom_fields', $input) || array_key_exists('supplier_field_order', $input)) {
|
|
||||||
/** @var array<mixed>|null $currentRaw */
|
|
||||||
$currentRaw = !empty($settings['custom_fields'])
|
|
||||||
? json_decode($settings['custom_fields'], true)
|
|
||||||
: null;
|
|
||||||
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
|
||||||
/** @var array<string, mixed> $stored */
|
|
||||||
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
|
||||||
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
|
||||||
/** @var array<string, mixed> $stored */
|
|
||||||
$stored = $currentRaw;
|
|
||||||
} else {
|
|
||||||
$stored = ['fields' => [], 'field_order' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
|
||||||
$stored['fields'] = $input['custom_fields'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('supplier_field_order', $input)) {
|
|
||||||
$stored['field_order'] = is_array($input['supplier_field_order']) ? $input['supplier_field_order'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Odstranit stary klic
|
|
||||||
unset($stored['fieldOrder']);
|
|
||||||
|
|
||||||
$setClauses[] = 'custom_fields = ?';
|
|
||||||
$params[] = json_encode($stored, JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace prefixu
|
|
||||||
if (isset($input['quotation_prefix']) && !preg_match('/^[A-Za-z0-9]{0,10}$/', $input['quotation_prefix'])) {
|
|
||||||
errorResponse('Prefix nabídky může obsahovat pouze alfanumerické znaky (max 10)');
|
|
||||||
}
|
|
||||||
if (isset($input['order_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['order_type_code'])) {
|
|
||||||
errorResponse('Typový kód objednávek může obsahovat pouze čísla (max 10)');
|
|
||||||
}
|
|
||||||
if (isset($input['invoice_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['invoice_type_code'])) {
|
|
||||||
errorResponse('Typový kód faktur může obsahovat pouze čísla (max 10)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$numericFields = ['default_vat_rate'];
|
|
||||||
foreach ($numericFields as $field) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$val = is_numeric($input[$field]) ? floatval($input[$field]) : 0;
|
|
||||||
if ($val < 0 || $val > 100) {
|
|
||||||
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
|
||||||
}
|
|
||||||
$setClauses[] = "$field = ?";
|
|
||||||
$params[] = $val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($setClauses)) {
|
|
||||||
errorResponse('Žádná data k aktualizaci');
|
|
||||||
}
|
|
||||||
|
|
||||||
$setClauses[] = 'modified_at = NOW()';
|
|
||||||
$setClauses[] = 'sync_version = sync_version + 1';
|
|
||||||
|
|
||||||
$sql = 'UPDATE company_settings SET ' . implode(', ', $setClauses) . ' WHERE id = ?';
|
|
||||||
$params[] = $settings['id'];
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logUpdate('company_settings', (int)$settings['id'], [], $input, 'Aktualizováno nastavení firmy');
|
|
||||||
|
|
||||||
successResponse(null, 'Nastavení bylo uloženo');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUploadLogo(PDO $pdo): void
|
|
||||||
{
|
|
||||||
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
|
|
||||||
errorResponse('Nebyl nahrán žádný soubor');
|
|
||||||
}
|
|
||||||
|
|
||||||
$file = $_FILES['logo'];
|
|
||||||
$allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
|
||||||
finfo_close($finfo);
|
|
||||||
|
|
||||||
if (!in_array($mimeType, $allowedTypes)) {
|
|
||||||
errorResponse('Nepodporovaný formát obrázku. Povolené: PNG, JPEG, GIF, WebP');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($file['size'] > 5 * 1024 * 1024) {
|
|
||||||
errorResponse('Soubor je příliš velký (max 5 MB)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$logoData = file_get_contents($file['tmp_name']);
|
|
||||||
|
|
||||||
$settings = getOrCreateSettings($pdo);
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'UPDATE company_settings SET logo_data = ?, modified_at = NOW(), sync_version = sync_version + 1 WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$logoData, $settings['id']]);
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'company_settings',
|
|
||||||
(int)$settings['id'],
|
|
||||||
[],
|
|
||||||
['logo' => 'uploaded'],
|
|
||||||
'Aktualizováno logo firmy'
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Logo bylo nahráno');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetLogo(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('SELECT logo_data FROM company_settings LIMIT 1');
|
|
||||||
$row = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$row || empty($row['logo_data'])) {
|
|
||||||
http_response_code(404);
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
echo json_encode(['success' => false, 'error' => 'Logo nenalezeno']);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
$logoData = $row['logo_data'];
|
|
||||||
|
|
||||||
// Detect image type from binary data
|
|
||||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
|
||||||
$mimeType = finfo_buffer($finfo, $logoData);
|
|
||||||
finfo_close($finfo);
|
|
||||||
|
|
||||||
header('Content-Type: ' . $mimeType);
|
|
||||||
header('Content-Length: ' . strlen($logoData));
|
|
||||||
header('Cache-Control: public, max-age=3600');
|
|
||||||
echo $logoData;
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/customers-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -76,275 +77,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $customer */
|
|
||||||
function parseCustomerCustomFields(array &$customer): void
|
|
||||||
{
|
|
||||||
/** @var array<mixed>|null $cfRaw */
|
|
||||||
$cfRaw = !empty($customer['custom_fields'])
|
|
||||||
? json_decode($customer['custom_fields'], true)
|
|
||||||
: null;
|
|
||||||
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
|
||||||
$customer['custom_fields'] = $cfRaw;
|
|
||||||
$customer['customer_field_order'] = null;
|
|
||||||
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
|
||||||
$customer['custom_fields'] = $cfRaw['fields'];
|
|
||||||
$customer['customer_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
|
||||||
} else {
|
|
||||||
$customer['custom_fields'] = [];
|
|
||||||
$customer['customer_field_order'] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $input */
|
|
||||||
function encodeCustomerCustomFields(array $input, ?string $existingJson): ?string
|
|
||||||
{
|
|
||||||
if (!array_key_exists('custom_fields', $input) && !array_key_exists('customer_field_order', $input)) {
|
|
||||||
return $existingJson;
|
|
||||||
}
|
|
||||||
/** @var array<mixed>|null $currentRaw */
|
|
||||||
$currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null;
|
|
||||||
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
|
||||||
/** @var array<string, mixed> $stored */
|
|
||||||
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
|
||||||
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
|
||||||
/** @var array<string, mixed> $stored */
|
|
||||||
$stored = $currentRaw;
|
|
||||||
} else {
|
|
||||||
$stored = ['fields' => [], 'field_order' => null];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
|
||||||
$stored['fields'] = $input['custom_fields'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('customer_field_order', $input)) {
|
|
||||||
$stored['field_order'] = is_array($input['customer_field_order']) ? $input['customer_field_order'] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
unset($stored['fieldOrder']);
|
|
||||||
|
|
||||||
return json_encode($stored, JSON_UNESCAPED_UNICODE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetAll(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT c.*, COUNT(q.id) as quotation_count
|
|
||||||
FROM customers c
|
|
||||||
LEFT JOIN quotations q ON q.customer_id = c.id
|
|
||||||
GROUP BY c.id
|
|
||||||
ORDER BY c.name ASC
|
|
||||||
');
|
|
||||||
$customers = $stmt->fetchAll();
|
|
||||||
|
|
||||||
foreach ($customers as &$c) {
|
|
||||||
parseCustomerCustomFields($c);
|
|
||||||
}
|
|
||||||
unset($c);
|
|
||||||
|
|
||||||
successResponse(['customers' => $customers]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetOne(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$customer = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$customer) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
parseCustomerCustomFields($customer);
|
|
||||||
successResponse($customer);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$q = trim($_GET['q'] ?? '');
|
|
||||||
if (strlen($q) < 1 || mb_strlen($q) > 100) {
|
|
||||||
successResponse(['customers' => []]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM customers
|
|
||||||
WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ?
|
|
||||||
ORDER BY name ASC
|
|
||||||
LIMIT 20
|
|
||||||
');
|
|
||||||
$search = "%{$q}%";
|
|
||||||
$stmt->execute([$search, $search, $search]);
|
|
||||||
|
|
||||||
$results = $stmt->fetchAll();
|
|
||||||
foreach ($results as &$c) {
|
|
||||||
parseCustomerCustomFields($c);
|
|
||||||
}
|
|
||||||
unset($c);
|
|
||||||
successResponse(['customers' => $results]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateCustomer(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
if (empty($input['name'])) {
|
|
||||||
errorResponse('Název zákazníka je povinný');
|
|
||||||
}
|
|
||||||
if (mb_strlen($input['name']) > 255) {
|
|
||||||
errorResponse('Název zákazníka je příliš dlouhý (max 255 znaků)');
|
|
||||||
}
|
|
||||||
foreach (['street', 'city', 'country'] as $f) {
|
|
||||||
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
|
||||||
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
|
||||||
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
|
||||||
}
|
|
||||||
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
|
||||||
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
|
||||||
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$uuid = sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0x0fff) | 0x4000,
|
|
||||||
random_int(0, 0x3fff) | 0x8000,
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff)
|
|
||||||
);
|
|
||||||
|
|
||||||
$customFieldsJson = encodeCustomerCustomFields($input, null);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO customers (name, street, city, postal_code, country,
|
|
||||||
company_id, vat_id, custom_fields, created_at, uuid, modified_at, sync_version)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), 1)
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'],
|
|
||||||
$input['street'] ?? '',
|
|
||||||
$input['city'] ?? '',
|
|
||||||
$input['postal_code'] ?? '',
|
|
||||||
$input['country'] ?? '',
|
|
||||||
$input['company_id'] ?? '',
|
|
||||||
$input['vat_id'] ?? '',
|
|
||||||
$customFieldsJson,
|
|
||||||
$uuid,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$newId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logCreate('customer', (int)$newId, [
|
|
||||||
'name' => $input['name'],
|
|
||||||
], "Vytvořen zákazník '{$input['name']}'");
|
|
||||||
|
|
||||||
successResponse(['id' => $newId], 'Zákazník byl vytvořen');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateCustomer(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$existing = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$existing) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
// Delkove limity
|
|
||||||
if (isset($input['name']) && mb_strlen($input['name']) > 255) {
|
|
||||||
errorResponse('Název je příliš dlouhý (max 255 znaků)');
|
|
||||||
}
|
|
||||||
foreach (['street', 'city', 'country'] as $f) {
|
|
||||||
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
|
||||||
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
|
||||||
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
|
||||||
}
|
|
||||||
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
|
||||||
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
|
||||||
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$customFieldsJson = encodeCustomerCustomFields($input, $existing['custom_fields'] ?? null);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE customers SET
|
|
||||||
name = ?,
|
|
||||||
street = ?,
|
|
||||||
city = ?,
|
|
||||||
postal_code = ?,
|
|
||||||
country = ?,
|
|
||||||
company_id = ?,
|
|
||||||
vat_id = ?,
|
|
||||||
custom_fields = ?,
|
|
||||||
modified_at = NOW(),
|
|
||||||
sync_version = sync_version + 1
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'] ?? $existing['name'],
|
|
||||||
$input['street'] ?? $existing['street'],
|
|
||||||
$input['city'] ?? $existing['city'],
|
|
||||||
$input['postal_code'] ?? $existing['postal_code'],
|
|
||||||
$input['country'] ?? $existing['country'],
|
|
||||||
$input['company_id'] ?? $existing['company_id'],
|
|
||||||
$input['vat_id'] ?? $existing['vat_id'],
|
|
||||||
$customFieldsJson,
|
|
||||||
$id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'customer',
|
|
||||||
$id,
|
|
||||||
['name' => $existing['name']],
|
|
||||||
['name' => $input['name'] ?? $existing['name']],
|
|
||||||
"Upraven zákazník #$id"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Zákazník byl aktualizován');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteCustomer(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$customer = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$customer) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if customer has quotations
|
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM quotations WHERE customer_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$count = (int)$stmt->fetchColumn();
|
|
||||||
|
|
||||||
if ($count > 0) {
|
|
||||||
errorResponse("Zákazníka nelze smazat, má $count nabídek");
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logDelete('customer', $id, ['name' => $customer['name']], "Smazán zákazník '{$customer['name']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Zákazník byl smazán');
|
|
||||||
}
|
|
||||||
|
|||||||
589
api/admin/handlers/attendance-handlers.php
Normal file
589
api/admin/handlers/attendance-handlers.php
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function handleGetCurrent(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT * FROM attendance
|
||||||
|
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$ongoingShift = $stmt->fetch();
|
||||||
|
|
||||||
|
$projectLogs = [];
|
||||||
|
$activeProjectId = null;
|
||||||
|
if ($ongoingShift) {
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
||||||
|
$stmt->execute([$ongoingShift['id']]);
|
||||||
|
$projectLogs = $stmt->fetchAll();
|
||||||
|
foreach ($projectLogs as $log) {
|
||||||
|
if ($log['ended_at'] === null) {
|
||||||
|
$activeProjectId = (int)$log['project_id'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT * FROM attendance
|
||||||
|
WHERE user_id = ? AND shift_date = ?
|
||||||
|
AND departure_time IS NOT NULL
|
||||||
|
AND (leave_type IS NULL OR leave_type = 'work')
|
||||||
|
ORDER BY arrival_time DESC
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $today]);
|
||||||
|
$todayShifts = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$completedShiftIds = array_column($todayShifts, 'id');
|
||||||
|
$completedProjectLogs = [];
|
||||||
|
if (!empty($completedShiftIds)) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($completedShiftIds), '?'));
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT * FROM attendance_project_logs
|
||||||
|
WHERE attendance_id IN ($placeholders)
|
||||||
|
ORDER BY started_at ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute($completedShiftIds);
|
||||||
|
$allLogs = $stmt->fetchAll();
|
||||||
|
foreach ($allLogs as $log) {
|
||||||
|
$completedProjectLogs[$log['attendance_id']][] = $log;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaveBalance = getLeaveBalance($pdo, $userId);
|
||||||
|
|
||||||
|
$currentYear = (int)date('Y');
|
||||||
|
$currentMonth = (int)date('m');
|
||||||
|
$fund = CzechHolidays::getMonthlyWorkFund($currentYear, $currentMonth);
|
||||||
|
$businessDays = CzechHolidays::getBusinessDaysInMonth($currentYear, $currentMonth);
|
||||||
|
|
||||||
|
$startDate = date('Y-m-01');
|
||||||
|
$endDate = date('Y-m-t');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM attendance
|
||||||
|
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $startDate, $endDate]);
|
||||||
|
$monthRecords = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$workedMinutes = 0;
|
||||||
|
$leaveHoursMonth = 0;
|
||||||
|
$vacationHours = 0;
|
||||||
|
$sickHours = 0;
|
||||||
|
$holidayHours = 0;
|
||||||
|
$unpaidHours = 0;
|
||||||
|
foreach ($monthRecords as $rec) {
|
||||||
|
$lt = $rec['leave_type'] ?? 'work';
|
||||||
|
$lh = (float)($rec['leave_hours'] ?? 0);
|
||||||
|
if ($lt === 'work') {
|
||||||
|
if ($rec['departure_time']) {
|
||||||
|
$workedMinutes += calculateWorkMinutes($rec);
|
||||||
|
}
|
||||||
|
} elseif ($lt === 'vacation') {
|
||||||
|
$vacationHours += $lh;
|
||||||
|
$leaveHoursMonth += $lh;
|
||||||
|
} elseif ($lt === 'sick') {
|
||||||
|
$sickHours += $lh;
|
||||||
|
$leaveHoursMonth += $lh;
|
||||||
|
} elseif ($lt === 'holiday') {
|
||||||
|
$holidayHours += $lh;
|
||||||
|
} elseif ($lt === 'unpaid') {
|
||||||
|
$unpaidHours += $lh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$workedHours = round($workedMinutes / 60, 1);
|
||||||
|
$covered = $workedHours + $leaveHoursMonth;
|
||||||
|
$remaining = max(0, $fund - $covered);
|
||||||
|
$overtime = max(0, round($covered - $fund, 1));
|
||||||
|
|
||||||
|
$monthlyFund = [
|
||||||
|
'fund' => $fund,
|
||||||
|
'business_days' => $businessDays,
|
||||||
|
'worked' => $workedHours,
|
||||||
|
'leave_hours' => $leaveHoursMonth,
|
||||||
|
'vacation_hours' => $vacationHours,
|
||||||
|
'sick_hours' => $sickHours,
|
||||||
|
'holiday_hours' => $holidayHours,
|
||||||
|
'unpaid_hours' => $unpaidHours,
|
||||||
|
'covered' => $covered,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'overtime' => $overtime,
|
||||||
|
'month_name' => getCzechMonthName($currentMonth) . ' ' . $currentYear,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Enrich project logs with names
|
||||||
|
$allLogProjectIds = [];
|
||||||
|
foreach ($projectLogs as $l) {
|
||||||
|
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
||||||
|
}
|
||||||
|
foreach ($completedProjectLogs as $logs) {
|
||||||
|
foreach ($logs as $l) {
|
||||||
|
$allLogProjectIds[$l['project_id']] = $l['project_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$projNameMap = fetchProjectNames($allLogProjectIds);
|
||||||
|
|
||||||
|
foreach ($projectLogs as &$l) {
|
||||||
|
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||||
|
}
|
||||||
|
unset($l);
|
||||||
|
foreach ($completedProjectLogs as &$logs) {
|
||||||
|
foreach ($logs as &$l) {
|
||||||
|
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||||
|
}
|
||||||
|
unset($l);
|
||||||
|
}
|
||||||
|
unset($logs);
|
||||||
|
|
||||||
|
foreach ($todayShifts as &$shift) {
|
||||||
|
$shift['project_logs'] = $completedProjectLogs[$shift['id']] ?? [];
|
||||||
|
}
|
||||||
|
unset($shift);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'ongoing_shift' => $ongoingShift,
|
||||||
|
'today_shifts' => $todayShifts,
|
||||||
|
'date' => $today,
|
||||||
|
'leave_balance' => $leaveBalance,
|
||||||
|
'monthly_fund' => $monthlyFund,
|
||||||
|
'project_logs' => $projectLogs,
|
||||||
|
'active_project_id' => $activeProjectId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetHistory(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$month = validateMonth();
|
||||||
|
$year = (int)substr($month, 0, 4);
|
||||||
|
$monthNum = (int)substr($month, 5, 2);
|
||||||
|
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM attendance
|
||||||
|
WHERE user_id = ? AND shift_date BETWEEN ? AND ?
|
||||||
|
ORDER BY shift_date DESC
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $startDate, $endDate]);
|
||||||
|
$records = $stmt->fetchAll();
|
||||||
|
|
||||||
|
enrichRecordsWithProjectLogs($pdo, $records);
|
||||||
|
|
||||||
|
$totalMinutes = 0;
|
||||||
|
$vacationHours = 0;
|
||||||
|
$sickHours = 0;
|
||||||
|
$holidayHours = 0;
|
||||||
|
$unpaidHours = 0;
|
||||||
|
|
||||||
|
foreach ($records as $record) {
|
||||||
|
$leaveType = $record['leave_type'] ?? 'work';
|
||||||
|
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||||||
|
|
||||||
|
if ($leaveType === 'vacation') {
|
||||||
|
$vacationHours += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'sick') {
|
||||||
|
$sickHours += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'holiday') {
|
||||||
|
$holidayHours += $leaveHours;
|
||||||
|
} elseif ($leaveType === 'unpaid') {
|
||||||
|
$unpaidHours += $leaveHours;
|
||||||
|
} else {
|
||||||
|
$totalMinutes += calculateWorkMinutes($record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||||
|
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||||||
|
$workedHours = round($totalMinutes / 60, 1);
|
||||||
|
$leaveHoursCovered = $vacationHours + $sickHours;
|
||||||
|
$covered = $workedHours + $leaveHoursCovered;
|
||||||
|
$remaining = max(0, round($fund - $covered, 1));
|
||||||
|
$overtime = max(0, round($covered - $fund, 1));
|
||||||
|
|
||||||
|
$leaveBalance = getLeaveBalance($pdo, $userId, $year);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'records' => $records,
|
||||||
|
'month' => $month,
|
||||||
|
'year' => $year,
|
||||||
|
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
||||||
|
'total_minutes' => $totalMinutes,
|
||||||
|
'vacation_hours' => $vacationHours,
|
||||||
|
'sick_hours' => $sickHours,
|
||||||
|
'holiday_hours' => $holidayHours,
|
||||||
|
'unpaid_hours' => $unpaidHours,
|
||||||
|
'leave_balance' => $leaveBalance,
|
||||||
|
'monthly_fund' => [
|
||||||
|
'fund' => $fund,
|
||||||
|
'business_days' => $businessDays,
|
||||||
|
'worked' => $workedHours,
|
||||||
|
'leave_hours' => $leaveHoursCovered,
|
||||||
|
'covered' => $covered,
|
||||||
|
'remaining' => $remaining,
|
||||||
|
'overtime' => $overtime,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePunch(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$action = $input['punch_action'] ?? '';
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$rawNow = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$lat = isset($input['latitude']) && $input['latitude'] !== '' ? (float)$input['latitude'] : null;
|
||||||
|
$lng = isset($input['longitude']) && $input['longitude'] !== '' ? (float)$input['longitude'] : null;
|
||||||
|
$accuracy = isset($input['accuracy']) && $input['accuracy'] !== '' ? (float)$input['accuracy'] : null;
|
||||||
|
$address = !empty($input['address']) ? $input['address'] : null;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT * FROM attendance
|
||||||
|
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$ongoingShift = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($action === 'arrival' && !$ongoingShift) {
|
||||||
|
$now = roundUpTo15Minutes($rawNow);
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO attendance
|
||||||
|
(user_id, shift_date, arrival_time, arrival_lat, arrival_lng, arrival_accuracy, arrival_address)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $today, $now, $lat, $lng, $accuracy, $address]);
|
||||||
|
|
||||||
|
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
||||||
|
'arrival_time' => $now,
|
||||||
|
'location' => $address,
|
||||||
|
], 'Příchod zaznamenán');
|
||||||
|
|
||||||
|
successResponse(null, 'Příchod zaznamenán');
|
||||||
|
} elseif ($ongoingShift) {
|
||||||
|
switch ($action) {
|
||||||
|
case 'break_start':
|
||||||
|
if ($ongoingShift['arrival_time'] && !$ongoingShift['break_start']) {
|
||||||
|
$breakStart = roundToNearest10Minutes($rawNow);
|
||||||
|
$breakEnd = date('Y-m-d H:i:s', strtotime($breakStart) + (30 * 60));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||||
|
|
||||||
|
successResponse(null, 'Pauza zaznamenána');
|
||||||
|
} else {
|
||||||
|
errorResponse('Nelze zadat pauzu');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'departure':
|
||||||
|
if ($ongoingShift['arrival_time'] && !$ongoingShift['departure_time']) {
|
||||||
|
$now = roundDownTo15Minutes($rawNow);
|
||||||
|
|
||||||
|
// Auto-add break if shift is longer than 6h and no break
|
||||||
|
if (!$ongoingShift['break_start'] && !$ongoingShift['break_end']) {
|
||||||
|
$arrivalTime = strtotime($ongoingShift['arrival_time']);
|
||||||
|
$departureTime = strtotime($now);
|
||||||
|
$hoursWorked = ($departureTime - $arrivalTime) / 3600;
|
||||||
|
|
||||||
|
if ($hoursWorked > 12) {
|
||||||
|
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
||||||
|
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (30 * 60)));
|
||||||
|
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (30 * 60)));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||||
|
} elseif ($hoursWorked > 6) {
|
||||||
|
$midPoint = $arrivalTime + (($departureTime - $arrivalTime) / 2);
|
||||||
|
$breakStart = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint - (15 * 60)));
|
||||||
|
$breakEnd = roundToNearest10Minutes(date('Y-m-d H:i:s', $midPoint + (15 * 60)));
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendance SET break_start = ?, break_end = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$breakStart, $breakEnd, $ongoingShift['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE attendance
|
||||||
|
SET departure_time = ?, departure_lat = ?, departure_lng = ?,
|
||||||
|
departure_accuracy = ?, departure_address = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$now, $lat, $lng, $accuracy, $address, $ongoingShift['id']]);
|
||||||
|
|
||||||
|
// Close any open project log
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE attendance_project_logs SET ended_at = ? WHERE attendance_id = ? AND ended_at IS NULL
|
||||||
|
');
|
||||||
|
$stmt->execute([$now, $ongoingShift['id']]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate('attendance', $ongoingShift['id'], [], [
|
||||||
|
'departure_time' => $now,
|
||||||
|
'location' => $address,
|
||||||
|
], 'Odchod zaznamenán');
|
||||||
|
|
||||||
|
successResponse(null, 'Odchod zaznamenán');
|
||||||
|
} else {
|
||||||
|
errorResponse('Nelze zadat odchod');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
errorResponse('Neplatná akce');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorResponse('Neplatná akce - nemáte aktivní směnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateAddress(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$address = trim($input['address'] ?? '');
|
||||||
|
$punchAction = $input['punch_action'] ?? '';
|
||||||
|
|
||||||
|
if (!$address) {
|
||||||
|
successResponse(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($punchAction === 'arrival') {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE attendance SET arrival_address = ?
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id FROM attendance
|
||||||
|
WHERE user_id = ? AND (arrival_address IS NULL OR arrival_address = '')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) t
|
||||||
|
)
|
||||||
|
");
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE attendance SET departure_address = ?
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM (
|
||||||
|
SELECT id FROM attendance
|
||||||
|
WHERE user_id = ? AND (departure_address IS NULL OR departure_address = '')
|
||||||
|
AND departure_time IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) t
|
||||||
|
)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
$stmt->execute([$address, $userId]);
|
||||||
|
|
||||||
|
successResponse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddLeave(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$leaveType = $input['leave_type'] ?? '';
|
||||||
|
$leaveDate = $input['leave_date'] ?? '';
|
||||||
|
$leaveHours = (float)($input['leave_hours'] ?? 8);
|
||||||
|
$notes = trim($input['notes'] ?? '');
|
||||||
|
|
||||||
|
if (!$leaveType || !$leaveDate || $leaveHours <= 0) {
|
||||||
|
errorResponse('Vyplňte všechna povinná pole');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
||||||
|
errorResponse('Neplatný typ nepřítomnosti');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($leaveType === 'vacation') {
|
||||||
|
$year = (int)date('Y', strtotime($leaveDate));
|
||||||
|
$balance = getLeaveBalance($pdo, $userId, $year);
|
||||||
|
|
||||||
|
if ($balance['vacation_remaining'] < $leaveHours) {
|
||||||
|
errorResponse(
|
||||||
|
"Nemáte dostatek hodin dovolené. Zbývá vám "
|
||||||
|
. "{$balance['vacation_remaining']} hodin, požadujete {$leaveHours} hodin."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $leaveDate, $leaveType, $leaveHours, $notes ?: null]);
|
||||||
|
|
||||||
|
updateLeaveBalance($pdo, $userId, $leaveDate, $leaveType, $leaveHours);
|
||||||
|
|
||||||
|
AuditLog::logCreate('attendance', (int)$pdo->lastInsertId(), [
|
||||||
|
'leave_type' => $leaveType,
|
||||||
|
'leave_hours' => $leaveHours,
|
||||||
|
], "Zaznamenána nepřítomnost: $leaveType");
|
||||||
|
|
||||||
|
successResponse(null, 'Nepřítomnost byla zaznamenána');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveNotes(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$notes = trim($input['notes'] ?? '');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id FROM attendance
|
||||||
|
WHERE user_id = ? AND departure_time IS NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$currentShift = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$currentShift) {
|
||||||
|
errorResponse('Nemáte aktivní směnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendance SET notes = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$notes, $currentShift['id']]);
|
||||||
|
|
||||||
|
successResponse(null, 'Poznámka byla uložena');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetProjects(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT id, project_number, name FROM projects
|
||||||
|
WHERE status = 'aktivni' ORDER BY project_number ASC"
|
||||||
|
);
|
||||||
|
$projects = $stmt->fetchAll();
|
||||||
|
successResponse(['projects' => $projects]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Failed to fetch projects: ' . $e->getMessage());
|
||||||
|
successResponse(['projects' => []]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitchProject(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
/** @var mixed $rawProjectId */
|
||||||
|
$rawProjectId = $input['project_id'] ?? null;
|
||||||
|
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||||
|
? (int)$rawProjectId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT id FROM attendance
|
||||||
|
WHERE user_id = ? AND departure_time IS NULL AND (leave_type IS NULL OR leave_type = 'work')
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$currentShift = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$currentShift) {
|
||||||
|
errorResponse('Nemáte aktivní směnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendanceId = $currentShift['id'];
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE attendance_project_logs SET ended_at = ?
|
||||||
|
WHERE attendance_id = ? AND ended_at IS NULL'
|
||||||
|
);
|
||||||
|
$stmt->execute([$now, $attendanceId]);
|
||||||
|
|
||||||
|
if ($projectId) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO attendance_project_logs
|
||||||
|
(attendance_id, project_id, started_at) VALUES (?, ?, ?)'
|
||||||
|
);
|
||||||
|
$stmt->execute([$attendanceId, $projectId, $now]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE attendance SET project_id = ? WHERE id = ?');
|
||||||
|
$stmt->execute([$projectId, $attendanceId]);
|
||||||
|
|
||||||
|
successResponse(null, $projectId ? 'Projekt přepnut' : 'Projekt zastaven');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $authData */
|
||||||
|
function handleGetProjectLogs(PDO $pdo, int $currentUserId, array $authData): void
|
||||||
|
{
|
||||||
|
$attendanceId = (int)($_GET['attendance_id'] ?? 0);
|
||||||
|
if (!$attendanceId) {
|
||||||
|
errorResponse('attendance_id je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ověření vlastnictví záznamu nebo admin oprávnění
|
||||||
|
if (!hasPermission($authData, 'attendance.admin')) {
|
||||||
|
$ownerStmt = $pdo->prepare('SELECT user_id FROM attendance WHERE id = ?');
|
||||||
|
$ownerStmt->execute([$attendanceId]);
|
||||||
|
$owner = $ownerStmt->fetch();
|
||||||
|
if (!$owner || (int)$owner['user_id'] !== $currentUserId) {
|
||||||
|
errorResponse('Nemáte oprávnění zobrazit tyto záznamy', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance_project_logs WHERE attendance_id = ? ORDER BY started_at ASC');
|
||||||
|
$stmt->execute([$attendanceId]);
|
||||||
|
$logs = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$projectIds = [];
|
||||||
|
foreach ($logs as $l) {
|
||||||
|
$projectIds[$l['project_id']] = $l['project_id'];
|
||||||
|
}
|
||||||
|
$projNameMap = fetchProjectNames($projectIds);
|
||||||
|
foreach ($logs as &$l) {
|
||||||
|
$l['project_name'] = $projNameMap[$l['project_id']] ?? null;
|
||||||
|
}
|
||||||
|
unset($l);
|
||||||
|
|
||||||
|
successResponse(['logs' => $logs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveProjectLogs(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$attendanceId = (int)($input['attendance_id'] ?? 0);
|
||||||
|
$logs = $input['project_logs'] ?? [];
|
||||||
|
|
||||||
|
if (!$attendanceId) {
|
||||||
|
errorResponse('attendance_id je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||||
|
$stmt->execute([$attendanceId]);
|
||||||
|
$record = $stmt->fetch();
|
||||||
|
if (!$record) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||||
|
$stmt->execute([$attendanceId]);
|
||||||
|
|
||||||
|
if (!empty($logs)) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'INSERT INTO attendance_project_logs
|
||||||
|
(attendance_id, project_id, hours, minutes) VALUES (?, ?, ?, ?)'
|
||||||
|
);
|
||||||
|
foreach ($logs as $log) {
|
||||||
|
$projectId = (int)($log['project_id'] ?? 0);
|
||||||
|
if (!$projectId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$h = (int)($log['hours'] ?? 0);
|
||||||
|
$m = (int)($log['minutes'] ?? 0);
|
||||||
|
if ($h === 0 && $m === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$stmt->execute([$attendanceId, $projectId, $h, $m]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(null, 'Projektové záznamy byly uloženy');
|
||||||
|
}
|
||||||
166
api/admin/handlers/bank-accounts-handlers.php
Normal file
166
api/admin/handlers/bank-accounts-handlers.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function handleGetBankAccountList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('SELECT * FROM bank_accounts ORDER BY position, id');
|
||||||
|
successResponse($stmt->fetchAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateBankAccount(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$accountName = trim($input['account_name'] ?? '');
|
||||||
|
$bankName = trim($input['bank_name'] ?? '');
|
||||||
|
$accountNumber = trim($input['account_number'] ?? '');
|
||||||
|
$iban = trim($input['iban'] ?? '');
|
||||||
|
$bic = trim($input['bic'] ?? '');
|
||||||
|
$currency = trim($input['currency'] ?? 'CZK');
|
||||||
|
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
||||||
|
|
||||||
|
if (!$accountName) {
|
||||||
|
errorResponse('Název účtu je povinný');
|
||||||
|
}
|
||||||
|
if (mb_strlen($accountName) > 100) {
|
||||||
|
errorResponse('Název účtu je příliš dlouhý (max 100 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bankName) > 255) {
|
||||||
|
errorResponse('Název banky je příliš dlouhý (max 255 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($accountNumber) > 50) {
|
||||||
|
errorResponse('Číslo účtu je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($iban) > 50) {
|
||||||
|
errorResponse('IBAN je příliš dlouhý (max 50 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bic) > 20) {
|
||||||
|
errorResponse('BIC/SWIFT je příliš dlouhý (max 20 znaků)');
|
||||||
|
}
|
||||||
|
if (!in_array($currency, ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zjistit dalsi pozici
|
||||||
|
$maxPos = (int) $pdo->query('SELECT COALESCE(MAX(position), 0) FROM bank_accounts')->fetchColumn();
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Pokud je default, zrusit ostatnim
|
||||||
|
if ($isDefault) {
|
||||||
|
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO bank_accounts
|
||||||
|
(account_name, bank_name, account_number, iban, bic, currency, is_default, position)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$accountName, $bankName, $accountNumber, $iban, $bic, $currency, $isDefault, $maxPos + 1]);
|
||||||
|
$newId = (int) $pdo->lastInsertId();
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logCreate(
|
||||||
|
'bank_account',
|
||||||
|
$newId,
|
||||||
|
['account_name' => $accountName],
|
||||||
|
"Vytvořen bankovní účet '$accountName'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(['id' => $newId], 'Bankovní účet byl vytvořen');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateBankAccount(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$account = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$account) {
|
||||||
|
errorResponse('Bankovní účet nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
// Delkove limity a validace
|
||||||
|
$maxLengths = ['account_name' => 100, 'bank_name' => 255, 'account_number' => 50, 'iban' => 50, 'bic' => 20];
|
||||||
|
foreach ($maxLengths as $f => $max) {
|
||||||
|
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
||||||
|
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = ['account_name', 'bank_name', 'account_number', 'iban', 'bic', 'currency'];
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$updates[] = "$field = ?";
|
||||||
|
$params[] = trim((string) $input[$field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
if (array_key_exists('is_default', $input)) {
|
||||||
|
$isDefault = !empty($input['is_default']) ? 1 : 0;
|
||||||
|
if ($isDefault) {
|
||||||
|
$pdo->exec('UPDATE bank_accounts SET is_default = 0');
|
||||||
|
}
|
||||||
|
$updates[] = 'is_default = ?';
|
||||||
|
$params[] = $isDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
errorResponse('Žádná data k aktualizaci');
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = 'modified_at = NOW()';
|
||||||
|
$params[] = $id;
|
||||||
|
|
||||||
|
$sql = 'UPDATE bank_accounts SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate('bank_account', $id, [], $input, "Aktualizován bankovní účet #{$id}");
|
||||||
|
|
||||||
|
successResponse(null, 'Bankovní účet byl aktualizován');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteBankAccount(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM bank_accounts WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$account = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$account) {
|
||||||
|
errorResponse('Bankovní účet nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM bank_accounts WHERE id = ?')->execute([$id]);
|
||||||
|
|
||||||
|
AuditLog::logDelete(
|
||||||
|
'bank_account',
|
||||||
|
$id,
|
||||||
|
['account_name' => $account['account_name']],
|
||||||
|
"Smazán bankovní účet '{$account['account_name']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Bankovní účet byl smazán');
|
||||||
|
}
|
||||||
247
api/admin/handlers/company-settings-handlers.php
Normal file
247
api/admin/handlers/company-settings-handlers.php
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $includeLogo false = bez logo_data BLOBu
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function getOrCreateSettings(PDO $pdo, bool $includeLogo = false): array
|
||||||
|
{
|
||||||
|
if ($includeLogo) {
|
||||||
|
$stmt = $pdo->query('SELECT * FROM company_settings LIMIT 1');
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT id, company_name, company_id, vat_id, street, city, postal_code, country,
|
||||||
|
quotation_prefix, default_currency, default_vat_rate,
|
||||||
|
custom_fields, uuid, modified_at, sync_version,
|
||||||
|
order_type_code, invoice_type_code, is_deleted,
|
||||||
|
CASE WHEN logo_data IS NOT NULL AND LENGTH(logo_data) > 0 THEN 1 ELSE 0 END as has_logo
|
||||||
|
FROM company_settings LIMIT 1
|
||||||
|
');
|
||||||
|
}
|
||||||
|
$settings = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$settings) {
|
||||||
|
$uuid = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0x0fff) | 0x4000,
|
||||||
|
random_int(0, 0x3fff) | 0x8000,
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff)
|
||||||
|
);
|
||||||
|
$pdo->prepare(
|
||||||
|
"INSERT INTO company_settings
|
||||||
|
(id, company_name, quotation_prefix, default_currency,
|
||||||
|
default_vat_rate, uuid, modified_at, sync_version)
|
||||||
|
VALUES (1, '', 'N', 'EUR', 21.0, ?, NOW(), 1)"
|
||||||
|
)->execute([$uuid]);
|
||||||
|
return getOrCreateSettings($pdo, $includeLogo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetOffersSettings(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$settings = getOrCreateSettings($pdo, false);
|
||||||
|
/** @var array<mixed>|null $cfRaw */
|
||||||
|
$cfRaw = !empty($settings['custom_fields'])
|
||||||
|
? json_decode($settings['custom_fields'], true)
|
||||||
|
: null;
|
||||||
|
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
||||||
|
$settings['custom_fields'] = $cfRaw;
|
||||||
|
$settings['supplier_field_order'] = null;
|
||||||
|
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
||||||
|
$settings['custom_fields'] = $cfRaw['fields'];
|
||||||
|
$settings['supplier_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
||||||
|
} else {
|
||||||
|
$settings['custom_fields'] = [];
|
||||||
|
$settings['supplier_field_order'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings['has_logo'] = (bool)($settings['has_logo'] ?? false);
|
||||||
|
|
||||||
|
successResponse($settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateOffersSettings(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$settings = getOrCreateSettings($pdo);
|
||||||
|
|
||||||
|
// Delkove limity
|
||||||
|
$maxLengths = [
|
||||||
|
'company_name' => 255, 'street' => 255, 'city' => 255,
|
||||||
|
'postal_code' => 20, 'country' => 100,
|
||||||
|
'company_id' => 50, 'vat_id' => 50,
|
||||||
|
'default_currency' => 5,
|
||||||
|
];
|
||||||
|
foreach ($maxLengths as $f => $max) {
|
||||||
|
if (isset($input[$f]) && mb_strlen(trim((string)$input[$f])) > $max) {
|
||||||
|
errorResponse("Pole $f je příliš dlouhé (max $max znaků)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validace meny
|
||||||
|
if (isset($input['default_currency']) && !in_array($input['default_currency'], ['EUR', 'USD', 'CZK', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [
|
||||||
|
'company_name', 'street', 'city', 'postal_code', 'country',
|
||||||
|
'company_id', 'vat_id',
|
||||||
|
'quotation_prefix', 'default_currency',
|
||||||
|
'order_type_code', 'invoice_type_code',
|
||||||
|
];
|
||||||
|
|
||||||
|
$setClauses = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
foreach ($fields as $field) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$setClauses[] = "$field = ?";
|
||||||
|
$params[] = $input[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// custom_fields + SupplierFieldOrder - ulozeny dohromady jako JSON
|
||||||
|
if (array_key_exists('custom_fields', $input) || array_key_exists('supplier_field_order', $input)) {
|
||||||
|
/** @var array<mixed>|null $currentRaw */
|
||||||
|
$currentRaw = !empty($settings['custom_fields'])
|
||||||
|
? json_decode($settings['custom_fields'], true)
|
||||||
|
: null;
|
||||||
|
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||||
|
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = $currentRaw;
|
||||||
|
} else {
|
||||||
|
$stored = ['fields' => [], 'field_order' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
||||||
|
$stored['fields'] = $input['custom_fields'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('supplier_field_order', $input)) {
|
||||||
|
$stored['field_order'] = is_array($input['supplier_field_order']) ? $input['supplier_field_order'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Odstranit stary klic
|
||||||
|
unset($stored['fieldOrder']);
|
||||||
|
|
||||||
|
$setClauses[] = 'custom_fields = ?';
|
||||||
|
$params[] = json_encode($stored, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace prefixu
|
||||||
|
if (isset($input['quotation_prefix']) && !preg_match('/^[A-Za-z0-9]{0,10}$/', $input['quotation_prefix'])) {
|
||||||
|
errorResponse('Prefix nabídky může obsahovat pouze alfanumerické znaky (max 10)');
|
||||||
|
}
|
||||||
|
if (isset($input['order_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['order_type_code'])) {
|
||||||
|
errorResponse('Typový kód objednávek může obsahovat pouze čísla (max 10)');
|
||||||
|
}
|
||||||
|
if (isset($input['invoice_type_code']) && !preg_match('/^[0-9]{0,10}$/', $input['invoice_type_code'])) {
|
||||||
|
errorResponse('Typový kód faktur může obsahovat pouze čísla (max 10)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$numericFields = ['default_vat_rate'];
|
||||||
|
foreach ($numericFields as $field) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$val = is_numeric($input[$field]) ? floatval($input[$field]) : 0;
|
||||||
|
if ($val < 0 || $val > 100) {
|
||||||
|
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
||||||
|
}
|
||||||
|
$setClauses[] = "$field = ?";
|
||||||
|
$params[] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($setClauses)) {
|
||||||
|
errorResponse('Žádná data k aktualizaci');
|
||||||
|
}
|
||||||
|
|
||||||
|
$setClauses[] = 'modified_at = NOW()';
|
||||||
|
$setClauses[] = 'sync_version = sync_version + 1';
|
||||||
|
|
||||||
|
$sql = 'UPDATE company_settings SET ' . implode(', ', $setClauses) . ' WHERE id = ?';
|
||||||
|
$params[] = $settings['id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logUpdate('company_settings', (int)$settings['id'], [], $input, 'Aktualizováno nastavení firmy');
|
||||||
|
|
||||||
|
successResponse(null, 'Nastavení bylo uloženo');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadLogo(PDO $pdo): void
|
||||||
|
{
|
||||||
|
if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
errorResponse('Nebyl nahrán žádný soubor');
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $_FILES['logo'];
|
||||||
|
$allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||||||
|
finfo_close($finfo);
|
||||||
|
|
||||||
|
if (!in_array($mimeType, $allowedTypes)) {
|
||||||
|
errorResponse('Nepodporovaný formát obrázku. Povolené: PNG, JPEG, GIF, WebP');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['size'] > 5 * 1024 * 1024) {
|
||||||
|
errorResponse('Soubor je příliš velký (max 5 MB)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoData = file_get_contents($file['tmp_name']);
|
||||||
|
|
||||||
|
$settings = getOrCreateSettings($pdo);
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE company_settings SET logo_data = ?, modified_at = NOW(), sync_version = sync_version + 1 WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$logoData, $settings['id']]);
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'company_settings',
|
||||||
|
(int)$settings['id'],
|
||||||
|
[],
|
||||||
|
['logo' => 'uploaded'],
|
||||||
|
'Aktualizováno logo firmy'
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Logo bylo nahráno');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetLogo(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('SELECT logo_data FROM company_settings LIMIT 1');
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row || empty($row['logo_data'])) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Logo nenalezeno']);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoData = $row['logo_data'];
|
||||||
|
|
||||||
|
// Detect image type from binary data
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = finfo_buffer($finfo, $logoData);
|
||||||
|
finfo_close($finfo);
|
||||||
|
|
||||||
|
header('Content-Type: ' . $mimeType);
|
||||||
|
header('Content-Length: ' . strlen($logoData));
|
||||||
|
header('Cache-Control: public, max-age=3600');
|
||||||
|
echo $logoData;
|
||||||
|
exit();
|
||||||
|
}
|
||||||
275
api/admin/handlers/customers-handlers.php
Normal file
275
api/admin/handlers/customers-handlers.php
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $customer */
|
||||||
|
function parseCustomerCustomFields(array &$customer): void
|
||||||
|
{
|
||||||
|
/** @var array<mixed>|null $cfRaw */
|
||||||
|
$cfRaw = !empty($customer['custom_fields'])
|
||||||
|
? json_decode($customer['custom_fields'], true)
|
||||||
|
: null;
|
||||||
|
if (is_array($cfRaw) && !isset($cfRaw['fields'])) {
|
||||||
|
$customer['custom_fields'] = $cfRaw;
|
||||||
|
$customer['customer_field_order'] = null;
|
||||||
|
} elseif (is_array($cfRaw) && isset($cfRaw['fields'])) {
|
||||||
|
$customer['custom_fields'] = $cfRaw['fields'];
|
||||||
|
$customer['customer_field_order'] = $cfRaw['field_order'] ?? $cfRaw['fieldOrder'] ?? null;
|
||||||
|
} else {
|
||||||
|
$customer['custom_fields'] = [];
|
||||||
|
$customer['customer_field_order'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $input */
|
||||||
|
function encodeCustomerCustomFields(array $input, ?string $existingJson): ?string
|
||||||
|
{
|
||||||
|
if (!array_key_exists('custom_fields', $input) && !array_key_exists('customer_field_order', $input)) {
|
||||||
|
return $existingJson;
|
||||||
|
}
|
||||||
|
/** @var array<mixed>|null $currentRaw */
|
||||||
|
$currentRaw = !empty($existingJson) ? json_decode($existingJson, true) : null;
|
||||||
|
if (is_array($currentRaw) && !isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = ['fields' => $currentRaw, 'field_order' => null];
|
||||||
|
} elseif (is_array($currentRaw) && isset($currentRaw['fields'])) {
|
||||||
|
/** @var array<string, mixed> $stored */
|
||||||
|
$stored = $currentRaw;
|
||||||
|
} else {
|
||||||
|
$stored = ['fields' => [], 'field_order' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('custom_fields', $input) && is_array($input['custom_fields'])) {
|
||||||
|
$stored['fields'] = $input['custom_fields'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('customer_field_order', $input)) {
|
||||||
|
$stored['field_order'] = is_array($input['customer_field_order']) ? $input['customer_field_order'] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($stored['fieldOrder']);
|
||||||
|
|
||||||
|
return json_encode($stored, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetAll(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT c.*, COUNT(q.id) as quotation_count
|
||||||
|
FROM customers c
|
||||||
|
LEFT JOIN quotations q ON q.customer_id = c.id
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY c.name ASC
|
||||||
|
');
|
||||||
|
$customers = $stmt->fetchAll();
|
||||||
|
|
||||||
|
foreach ($customers as &$c) {
|
||||||
|
parseCustomerCustomFields($c);
|
||||||
|
}
|
||||||
|
unset($c);
|
||||||
|
|
||||||
|
successResponse(['customers' => $customers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetOne(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$customer = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$customer) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCustomerCustomFields($customer);
|
||||||
|
successResponse($customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$q = trim($_GET['q'] ?? '');
|
||||||
|
if (strlen($q) < 1 || mb_strlen($q) > 100) {
|
||||||
|
successResponse(['customers' => []]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM customers
|
||||||
|
WHERE name LIKE ? OR company_id LIKE ? OR city LIKE ?
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT 20
|
||||||
|
');
|
||||||
|
$search = "%{$q}%";
|
||||||
|
$stmt->execute([$search, $search, $search]);
|
||||||
|
|
||||||
|
$results = $stmt->fetchAll();
|
||||||
|
foreach ($results as &$c) {
|
||||||
|
parseCustomerCustomFields($c);
|
||||||
|
}
|
||||||
|
unset($c);
|
||||||
|
successResponse(['customers' => $results]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateCustomer(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
if (empty($input['name'])) {
|
||||||
|
errorResponse('Název zákazníka je povinný');
|
||||||
|
}
|
||||||
|
if (mb_strlen($input['name']) > 255) {
|
||||||
|
errorResponse('Název zákazníka je příliš dlouhý (max 255 znaků)');
|
||||||
|
}
|
||||||
|
foreach (['street', 'city', 'country'] as $f) {
|
||||||
|
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
||||||
|
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
||||||
|
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
||||||
|
}
|
||||||
|
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
||||||
|
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
||||||
|
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$uuid = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0x0fff) | 0x4000,
|
||||||
|
random_int(0, 0x3fff) | 0x8000,
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
$customFieldsJson = encodeCustomerCustomFields($input, null);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO customers (name, street, city, postal_code, country,
|
||||||
|
company_id, vat_id, custom_fields, created_at, uuid, modified_at, sync_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), ?, NOW(), 1)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'],
|
||||||
|
$input['street'] ?? '',
|
||||||
|
$input['city'] ?? '',
|
||||||
|
$input['postal_code'] ?? '',
|
||||||
|
$input['country'] ?? '',
|
||||||
|
$input['company_id'] ?? '',
|
||||||
|
$input['vat_id'] ?? '',
|
||||||
|
$customFieldsJson,
|
||||||
|
$uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logCreate('customer', (int)$newId, [
|
||||||
|
'name' => $input['name'],
|
||||||
|
], "Vytvořen zákazník '{$input['name']}'");
|
||||||
|
|
||||||
|
successResponse(['id' => $newId], 'Zákazník byl vytvořen');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateCustomer(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
// Delkove limity
|
||||||
|
if (isset($input['name']) && mb_strlen($input['name']) > 255) {
|
||||||
|
errorResponse('Název je příliš dlouhý (max 255 znaků)');
|
||||||
|
}
|
||||||
|
foreach (['street', 'city', 'country'] as $f) {
|
||||||
|
if (isset($input[$f]) && mb_strlen($input[$f]) > 255) {
|
||||||
|
errorResponse("Pole $f je příliš dlouhé (max 255 znaků)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($input['postal_code']) && mb_strlen($input['postal_code']) > 20) {
|
||||||
|
errorResponse('PSČ je příliš dlouhé (max 20 znaků)');
|
||||||
|
}
|
||||||
|
if (isset($input['company_id']) && mb_strlen($input['company_id']) > 50) {
|
||||||
|
errorResponse('IČO je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
if (isset($input['vat_id']) && mb_strlen($input['vat_id']) > 50) {
|
||||||
|
errorResponse('DIČ je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$customFieldsJson = encodeCustomerCustomFields($input, $existing['custom_fields'] ?? null);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE customers SET
|
||||||
|
name = ?,
|
||||||
|
street = ?,
|
||||||
|
city = ?,
|
||||||
|
postal_code = ?,
|
||||||
|
country = ?,
|
||||||
|
company_id = ?,
|
||||||
|
vat_id = ?,
|
||||||
|
custom_fields = ?,
|
||||||
|
modified_at = NOW(),
|
||||||
|
sync_version = sync_version + 1
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'] ?? $existing['name'],
|
||||||
|
$input['street'] ?? $existing['street'],
|
||||||
|
$input['city'] ?? $existing['city'],
|
||||||
|
$input['postal_code'] ?? $existing['postal_code'],
|
||||||
|
$input['country'] ?? $existing['country'],
|
||||||
|
$input['company_id'] ?? $existing['company_id'],
|
||||||
|
$input['vat_id'] ?? $existing['vat_id'],
|
||||||
|
$customFieldsJson,
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'customer',
|
||||||
|
$id,
|
||||||
|
['name' => $existing['name']],
|
||||||
|
['name' => $input['name'] ?? $existing['name']],
|
||||||
|
"Upraven zákazník #$id"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Zákazník byl aktualizován');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteCustomer(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$customer = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$customer) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if customer has quotations
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM quotations WHERE customer_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$count = (int)$stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
errorResponse("Zákazníka nelze smazat, má $count nabídek");
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logDelete('customer', $id, ['name' => $customer['name']], "Smazán zákazník '{$customer['name']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Zákazník byl smazán');
|
||||||
|
}
|
||||||
705
api/admin/handlers/invoices-handlers.php
Normal file
705
api/admin/handlers/invoices-handlers.php
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
function getValidTransitions(string $status): array
|
||||||
|
{
|
||||||
|
return match ($status) {
|
||||||
|
'issued' => ['paid'],
|
||||||
|
'overdue' => ['paid'],
|
||||||
|
default => []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Invoice number generation ---
|
||||||
|
|
||||||
|
function generateInvoiceNumber(PDO $pdo): string
|
||||||
|
{
|
||||||
|
$yy = date('y');
|
||||||
|
|
||||||
|
$settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch();
|
||||||
|
$typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81';
|
||||||
|
|
||||||
|
$prefix = $yy . $typeCode;
|
||||||
|
$prefixLen = strlen($prefix);
|
||||||
|
$likePattern = $prefix . '%';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0)
|
||||||
|
FROM invoices WHERE invoice_number LIKE ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$prefixLen, $likePattern]);
|
||||||
|
$max = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return sprintf('%s%04d', $prefix, $max + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury.
|
||||||
|
*
|
||||||
|
* @param array<int, string|int|float> $params
|
||||||
|
* @return array{amounts: array<int, array{amount: float, currency: string}>, count: int, total_czk: float}
|
||||||
|
*/
|
||||||
|
function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array
|
||||||
|
{
|
||||||
|
// Per-faktura pro presny prepocet kurzem k datu
|
||||||
|
$perInvoiceSql = "
|
||||||
|
SELECT i.id, i.currency, i.issue_date,
|
||||||
|
COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
||||||
|
+ COALESCE(SUM(CASE WHEN i.apply_vat
|
||||||
|
THEN ii.quantity * ii.unit_price * ii.vat_rate / 100
|
||||||
|
ELSE 0 END), 0) AS total
|
||||||
|
FROM invoices i
|
||||||
|
JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||||
|
$where
|
||||||
|
GROUP BY i.id, i.currency, i.issue_date
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($perInvoiceSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$rows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Seskupit podle meny pro zobrazeni
|
||||||
|
$byCurrency = [];
|
||||||
|
$czkItems = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$cur = $r['currency'];
|
||||||
|
$amt = round((float) $r['total'], 2);
|
||||||
|
$byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt;
|
||||||
|
$czkItems[] = [
|
||||||
|
'amount' => $amt,
|
||||||
|
'currency' => $cur,
|
||||||
|
'date' => $r['issue_date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$amounts = [];
|
||||||
|
foreach ($byCurrency as $cur => $total) {
|
||||||
|
$amounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cnb = CnbRates::getInstance();
|
||||||
|
$totalCzk = $cnb->sumToCzk($czkItems);
|
||||||
|
|
||||||
|
$countSql = "SELECT COUNT(*) FROM invoices i $where";
|
||||||
|
$countStmt = $pdo->prepare($countSql);
|
||||||
|
$countStmt->execute($params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'amounts' => $amounts,
|
||||||
|
'count' => (int) $countStmt->fetchColumn(),
|
||||||
|
'total_czk' => $totalCzk,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetStats(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||||
|
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||||
|
|
||||||
|
// Lazy overdue detekce
|
||||||
|
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||||
|
|
||||||
|
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
||||||
|
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
||||||
|
|
||||||
|
// a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni)
|
||||||
|
$paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?";
|
||||||
|
$paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]);
|
||||||
|
|
||||||
|
// b) Ceka uhrada (aktualni stav)
|
||||||
|
$awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []);
|
||||||
|
|
||||||
|
// c) Po splatnosti (aktualni stav)
|
||||||
|
$overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []);
|
||||||
|
|
||||||
|
// d) DPH v danem mesici - per faktura pro kurz k datu
|
||||||
|
$vatSql = "
|
||||||
|
SELECT i.id, i.currency, i.issue_date,
|
||||||
|
COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total
|
||||||
|
FROM invoices i
|
||||||
|
JOIN invoice_items ii ON ii.invoice_id = i.id
|
||||||
|
WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ?
|
||||||
|
GROUP BY i.id, i.currency, i.issue_date
|
||||||
|
";
|
||||||
|
$vatStmt = $pdo->prepare($vatSql);
|
||||||
|
$vatStmt->execute([$monthStart, $monthEnd]);
|
||||||
|
$vatRows = $vatStmt->fetchAll();
|
||||||
|
|
||||||
|
$vatByCurrency = [];
|
||||||
|
$vatCzkItems = [];
|
||||||
|
foreach ($vatRows as $r) {
|
||||||
|
$cur = $r['currency'];
|
||||||
|
$amt = round((float) $r['vat_total'], 2);
|
||||||
|
$vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt;
|
||||||
|
$vatCzkItems[] = [
|
||||||
|
'amount' => $amt,
|
||||||
|
'currency' => $cur,
|
||||||
|
'date' => $r['issue_date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$vatAmounts = [];
|
||||||
|
foreach ($vatByCurrency as $cur => $total) {
|
||||||
|
$vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cnb = CnbRates::getInstance();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'paid_month' => $paid['amounts'],
|
||||||
|
'paid_month_czk' => $paid['total_czk'],
|
||||||
|
'paid_month_count' => $paid['count'],
|
||||||
|
'awaiting' => $awaiting['amounts'],
|
||||||
|
'awaiting_czk' => $awaiting['total_czk'],
|
||||||
|
'awaiting_count' => $awaiting['count'],
|
||||||
|
'overdue' => $overdue['amounts'],
|
||||||
|
'overdue_czk' => $overdue['total_czk'],
|
||||||
|
'overdue_count' => $overdue['count'],
|
||||||
|
'vat_month' => $vatAmounts,
|
||||||
|
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
||||||
|
'month' => $month,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
function handleGetList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$statusFilter = trim($_GET['status'] ?? '');
|
||||||
|
$sort = $_GET['sort'] ?? 'created_at';
|
||||||
|
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||||
|
|
||||||
|
$sortMap = [
|
||||||
|
'InvoiceNumber' => 'i.invoice_number',
|
||||||
|
'invoice_number' => 'i.invoice_number',
|
||||||
|
'CreatedAt' => 'i.created_at',
|
||||||
|
'created_at' => 'i.created_at',
|
||||||
|
'Status' => 'i.status',
|
||||||
|
'status' => 'i.status',
|
||||||
|
'DueDate' => 'i.due_date',
|
||||||
|
'due_date' => 'i.due_date',
|
||||||
|
'IssueDate' => 'i.issue_date',
|
||||||
|
'issue_date' => 'i.issue_date',
|
||||||
|
];
|
||||||
|
if (!isset($sortMap[$sort])) {
|
||||||
|
errorResponse('Neplatný parametr řazení', 400);
|
||||||
|
}
|
||||||
|
$sortCol = $sortMap[$sort];
|
||||||
|
|
||||||
|
// Lazy overdue detekce
|
||||||
|
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
||||||
|
|
||||||
|
$where = 'WHERE 1=1';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search = mb_substr($search, 0, 100);
|
||||||
|
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
|
||||||
|
$searchParam = "%{$search}%";
|
||||||
|
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($statusFilter) {
|
||||||
|
$statuses = array_filter(explode(',', $statusFilter));
|
||||||
|
if ($statuses) {
|
||||||
|
$placeholders = implode(',', array_fill(0, count($statuses), '?'));
|
||||||
|
$where .= " AND i.status IN ($placeholders)";
|
||||||
|
$params = array_merge($params, $statuses);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
$where
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($countSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency,
|
||||||
|
i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat,
|
||||||
|
c.name as customer_name,
|
||||||
|
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
||||||
|
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
|
||||||
|
o.order_number
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
LEFT JOIN orders o ON i.order_id = o.id
|
||||||
|
$where
|
||||||
|
ORDER BY $sortCol $order
|
||||||
|
LIMIT $perPage OFFSET $offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Dopocitat celkovou castku s DPH
|
||||||
|
foreach ($invoices as &$inv) {
|
||||||
|
$subtotal = (float) $inv['subtotal'];
|
||||||
|
if ($inv['apply_vat']) {
|
||||||
|
$vatStmt = $pdo->prepare('
|
||||||
|
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0)
|
||||||
|
FROM invoice_items WHERE invoice_id = ?
|
||||||
|
');
|
||||||
|
$vatStmt->execute([$inv['id']]);
|
||||||
|
$vatAmount = (float) $vatStmt->fetchColumn();
|
||||||
|
$inv['total'] = $subtotal + $vatAmount;
|
||||||
|
} else {
|
||||||
|
$inv['total'] = $subtotal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($inv);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'invoices' => $invoices,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
// Lazy overdue
|
||||||
|
$pdo->prepare(
|
||||||
|
"UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()"
|
||||||
|
)->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT i.*, c.name as customer_name, o.order_number
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
LEFT JOIN orders o ON i.order_id = o.id
|
||||||
|
WHERE i.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polozky
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice['items'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Zakaznik
|
||||||
|
if ($invoice['customer_id']) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
||||||
|
FROM customers WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$invoice['customer_id']]);
|
||||||
|
$invoice['customer'] = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoice['valid_transitions'] = getValidTransitions($invoice['status']);
|
||||||
|
|
||||||
|
successResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetNextNumber(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$number = generateInvoiceNumber($pdo);
|
||||||
|
successResponse(['number' => $number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetOrderData(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT o.id, o.order_number, o.customer_id, o.status, o.currency,
|
||||||
|
o.language, o.vat_rate, o.apply_vat, o.exchange_rate,
|
||||||
|
o.created_at, o.modified_at,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN customers c ON o.customer_id = c.id
|
||||||
|
WHERE o.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
errorResponse('Objednávka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polozky objednavky
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order['items'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse($order);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $authData */
|
||||||
|
function handleCreateInvoice(PDO $pdo, array $authData): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null;
|
||||||
|
$orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null;
|
||||||
|
$issueDate = trim($input['issue_date'] ?? '');
|
||||||
|
$dueDate = trim($input['due_date'] ?? '');
|
||||||
|
$taxDate = trim($input['tax_date'] ?? '');
|
||||||
|
$currency = trim($input['currency'] ?? 'CZK');
|
||||||
|
$applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1;
|
||||||
|
$paymentMethod = trim($input['payment_method'] ?? 'Příkazem');
|
||||||
|
$constantSymbol = trim($input['constant_symbol'] ?? '0308');
|
||||||
|
$issuedBy = trim($input['issued_by'] ?? '');
|
||||||
|
$notes = trim($input['notes'] ?? '');
|
||||||
|
$items = $input['items'] ?? [];
|
||||||
|
|
||||||
|
// Bankovni udaje
|
||||||
|
$bankName = trim($input['bank_name'] ?? '');
|
||||||
|
$bankSwift = trim($input['bank_swift'] ?? '');
|
||||||
|
$bankIban = trim($input['bank_iban'] ?? '');
|
||||||
|
$bankAccount = trim($input['bank_account'] ?? '');
|
||||||
|
|
||||||
|
if (!$customerId) {
|
||||||
|
errorResponse('Zákazník je povinný');
|
||||||
|
}
|
||||||
|
if (!$issueDate || !$dueDate || !$taxDate) {
|
||||||
|
errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace formatu dat
|
||||||
|
foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) {
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
|
||||||
|
errorResponse("Neplatný formát data: $label");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace meny
|
||||||
|
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
||||||
|
if (!in_array($currency, $validCurrencies)) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delkove limity
|
||||||
|
if (mb_strlen($paymentMethod) > 50) {
|
||||||
|
errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($issuedBy) > 255) {
|
||||||
|
errorResponse('Vystavil je příliš dlouhé (max 255 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($notes) > 5000) {
|
||||||
|
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bankName) > 255) {
|
||||||
|
errorResponse('Název banky je příliš dlouhý');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bankIban) > 50) {
|
||||||
|
errorResponse('IBAN je příliš dlouhý');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bankSwift) > 20) {
|
||||||
|
errorResponse('BIC/SWIFT je příliš dlouhý');
|
||||||
|
}
|
||||||
|
if (mb_strlen($bankAccount) > 50) {
|
||||||
|
errorResponse('Číslo účtu je příliš dlouhé');
|
||||||
|
}
|
||||||
|
if (!$bankAccount && !$bankIban) {
|
||||||
|
errorResponse('Bankovní účet je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($items)) {
|
||||||
|
errorResponse('Faktura musí mít alespoň jednu položku');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace polozek
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
$qty = $item['quantity'] ?? 1;
|
||||||
|
$price = $item['unit_price'] ?? 0;
|
||||||
|
$vatRate = $item['vat_rate'] ?? 21;
|
||||||
|
if (!is_numeric($qty) || $qty < 0) {
|
||||||
|
errorResponse('Položka #' . ($i + 1) . ': neplatné množství');
|
||||||
|
}
|
||||||
|
if (!is_numeric($price)) {
|
||||||
|
errorResponse('Položka #' . ($i + 1) . ': neplatná cena');
|
||||||
|
}
|
||||||
|
if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) {
|
||||||
|
errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH');
|
||||||
|
}
|
||||||
|
if (mb_strlen($item['description'] ?? '') > 500) {
|
||||||
|
errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overit zakaznika
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$customerId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock pro cislovani
|
||||||
|
$locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn();
|
||||||
|
if (!$locked) {
|
||||||
|
errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$invoiceNumber = !empty($input['invoice_number'])
|
||||||
|
? trim($input['invoice_number'])
|
||||||
|
: generateInvoiceNumber($pdo);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO invoices (
|
||||||
|
invoice_number, order_id, customer_id, status, currency,
|
||||||
|
vat_rate, apply_vat, payment_method, constant_symbol,
|
||||||
|
bank_name, bank_swift, bank_iban, bank_account,
|
||||||
|
issue_date, due_date, tax_date, issued_by, notes,
|
||||||
|
created_at, modified_at
|
||||||
|
) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$invoiceNumber,
|
||||||
|
$orderId,
|
||||||
|
$customerId,
|
||||||
|
$currency,
|
||||||
|
$input['vat_rate'] ?? 21,
|
||||||
|
$applyVat,
|
||||||
|
$paymentMethod,
|
||||||
|
$constantSymbol,
|
||||||
|
$bankName,
|
||||||
|
$bankSwift,
|
||||||
|
$bankIban,
|
||||||
|
$bankAccount,
|
||||||
|
$issueDate,
|
||||||
|
$dueDate,
|
||||||
|
$taxDate,
|
||||||
|
$issuedBy,
|
||||||
|
$notes,
|
||||||
|
]);
|
||||||
|
$invoiceId = (int) $pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Vlozit polozky
|
||||||
|
$itemStmt = $pdo->prepare('
|
||||||
|
INSERT INTO invoice_items (
|
||||||
|
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
$itemStmt->execute([
|
||||||
|
$invoiceId,
|
||||||
|
trim($item['description'] ?? ''),
|
||||||
|
$item['quantity'] ?? 1,
|
||||||
|
trim($item['unit'] ?? ''),
|
||||||
|
$item['unit_price'] ?? 0,
|
||||||
|
$item['vat_rate'] ?? 21,
|
||||||
|
$item['position'] ?? $i,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
||||||
|
|
||||||
|
AuditLog::logCreate('invoices_invoice', $invoiceId, [
|
||||||
|
'invoice_number' => $invoiceNumber,
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'order_id' => $orderId,
|
||||||
|
], "Vytvořena faktura '$invoiceNumber'");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'invoice_id' => $invoiceId,
|
||||||
|
'invoice_number' => $invoiceNumber,
|
||||||
|
], 'Faktura byla vystavena');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateInvoice(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$newStatus = $input['status'] ?? null;
|
||||||
|
$isDraft = $invoice['status'] === 'issued';
|
||||||
|
|
||||||
|
// Zmena stavu
|
||||||
|
if ($newStatus && $newStatus !== $invoice['status']) {
|
||||||
|
$valid = getValidTransitions($invoice['status']);
|
||||||
|
if (!in_array($newStatus, $valid)) {
|
||||||
|
errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($newStatus !== null && $newStatus !== $invoice['status']) {
|
||||||
|
$updates[] = 'status = ?';
|
||||||
|
$params[] = $newStatus;
|
||||||
|
|
||||||
|
if ($newStatus === 'paid') {
|
||||||
|
$updates[] = 'paid_date = CURDATE()';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// V issued stavu lze editovat vsechna pole
|
||||||
|
if ($isDraft) {
|
||||||
|
// Validace dat
|
||||||
|
foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) {
|
||||||
|
if (
|
||||||
|
isset($input[$dateField])
|
||||||
|
&& (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField]))
|
||||||
|
) {
|
||||||
|
errorResponse("Neplatný formát data: $dateField");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validace meny
|
||||||
|
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
// Validace DPH
|
||||||
|
if (
|
||||||
|
isset($input['vat_rate'])
|
||||||
|
&& (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100)
|
||||||
|
) {
|
||||||
|
errorResponse('Neplatná sazba DPH');
|
||||||
|
}
|
||||||
|
// Validace zakaznika
|
||||||
|
if (isset($input['customer_id'])) {
|
||||||
|
$custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||||
|
$custStmt->execute([(int)$input['customer_id']]);
|
||||||
|
if (!$custStmt->fetch()) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringFields = [
|
||||||
|
'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20,
|
||||||
|
'payment_method' => 50, 'constant_symbol' => 10,
|
||||||
|
'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50,
|
||||||
|
'issued_by' => 255,
|
||||||
|
];
|
||||||
|
foreach ($stringFields as $field => $maxLen) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$val = trim((string)$input[$field]);
|
||||||
|
if (mb_strlen($val) > $maxLen) {
|
||||||
|
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
||||||
|
}
|
||||||
|
$updates[] = "$field = ?";
|
||||||
|
$params[] = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id'];
|
||||||
|
foreach ($numericFields as $field) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$updates[] = "$field = ?";
|
||||||
|
$params[] = $input[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualizace polozek
|
||||||
|
if (isset($input['items']) && is_array($input['items'])) {
|
||||||
|
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
||||||
|
|
||||||
|
$itemStmt = $pdo->prepare('
|
||||||
|
INSERT INTO invoice_items (
|
||||||
|
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
foreach ($input['items'] as $i => $item) {
|
||||||
|
$itemStmt->execute([
|
||||||
|
$id,
|
||||||
|
trim($item['description'] ?? ''),
|
||||||
|
$item['quantity'] ?? 1,
|
||||||
|
trim($item['unit'] ?? ''),
|
||||||
|
$item['unit_price'] ?? 0,
|
||||||
|
$item['vat_rate'] ?? 21,
|
||||||
|
$item['position'] ?? $i,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poznamky lze editovat jen v issued/overdue stavu
|
||||||
|
if ($isDraft || $invoice['status'] === 'overdue') {
|
||||||
|
if (array_key_exists('notes', $input)) {
|
||||||
|
$updates[] = 'notes = ?';
|
||||||
|
$params[] = $input['notes'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('internal_notes', $input)) {
|
||||||
|
$updates[] = 'internal_notes = ?';
|
||||||
|
$params[] = $input['internal_notes'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($updates)) {
|
||||||
|
$updates[] = 'modified_at = NOW()';
|
||||||
|
$params[] = $id;
|
||||||
|
$sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'invoices_invoice',
|
||||||
|
$id,
|
||||||
|
['status' => $invoice['status']],
|
||||||
|
['status' => $newStatus ?? $invoice['status']],
|
||||||
|
"Aktualizována faktura '{$invoice['invoice_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Faktura byla aktualizována');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteInvoice(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
||||||
|
$pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logDelete('invoices_invoice', $id, [
|
||||||
|
'invoice_number' => $invoice['invoice_number'],
|
||||||
|
'customer_id' => $invoice['customer_id'],
|
||||||
|
], "Smazána faktura '{$invoice['invoice_number']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Faktura byla smazána');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
462
api/admin/handlers/leave-requests-handlers.php
Normal file
462
api/admin/handlers/leave-requests-handlers.php
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate number of business days between two dates (skip Sat/Sun)
|
||||||
|
*/
|
||||||
|
function calculateBusinessDays(string $dateFrom, string $dateTo): int
|
||||||
|
{
|
||||||
|
$start = new DateTime($dateFrom);
|
||||||
|
$end = new DateTime($dateTo);
|
||||||
|
$end->modify('+1 day'); // include the end date
|
||||||
|
|
||||||
|
$days = 0;
|
||||||
|
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||||
|
foreach ($period as $date) {
|
||||||
|
$dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun
|
||||||
|
if ($dayOfWeek <= 5) {
|
||||||
|
$days++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get leave balance for user (reuse logic from attendance.php)
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array
|
||||||
|
{
|
||||||
|
$year = $year ?: (int)date('Y');
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
$balance = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$balance) {
|
||||||
|
return [
|
||||||
|
'vacation_total' => 160,
|
||||||
|
'vacation_used' => 0,
|
||||||
|
'vacation_remaining' => 160,
|
||||||
|
'sick_used' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'vacation_total' => (float)$balance['vacation_total'],
|
||||||
|
'vacation_used' => (float)$balance['vacation_used'],
|
||||||
|
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
||||||
|
'sick_used' => (float)$balance['sick_used'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hours already locked in pending requests for vacation
|
||||||
|
*/
|
||||||
|
function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT COALESCE(SUM(total_hours), 0) as pending_hours
|
||||||
|
FROM leave_requests
|
||||||
|
WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending'
|
||||||
|
AND YEAR(date_from) = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $year]);
|
||||||
|
return (float)$stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Own leave requests
|
||||||
|
*/
|
||||||
|
function handleGetMyRequests(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT lr.*,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as reviewer_name
|
||||||
|
FROM leave_requests lr
|
||||||
|
LEFT JOIN users u ON lr.reviewer_id = u.id
|
||||||
|
WHERE lr.user_id = ?
|
||||||
|
ORDER BY lr.created_at DESC
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$requests = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse($requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - All pending requests (for approver)
|
||||||
|
*/
|
||||||
|
function handleGetPending(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT lr.*,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
||||||
|
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||||
|
FROM leave_requests lr
|
||||||
|
JOIN users u ON lr.user_id = u.id
|
||||||
|
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
||||||
|
WHERE lr.status = 'pending'
|
||||||
|
ORDER BY lr.created_at ASC
|
||||||
|
");
|
||||||
|
$stmt->execute();
|
||||||
|
$requests = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'requests' => $requests,
|
||||||
|
'count' => count($requests),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - All requests with filters (for approver)
|
||||||
|
*/
|
||||||
|
function handleGetAll(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$status = $_GET['status'] ?? '';
|
||||||
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||||
|
|
||||||
|
$where = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) {
|
||||||
|
$where[] = 'lr.status = ?';
|
||||||
|
$params[] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($userId) {
|
||||||
|
$where[] = 'lr.user_id = ?';
|
||||||
|
$params[] = $userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT lr.*,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
||||||
|
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
||||||
|
FROM leave_requests lr
|
||||||
|
JOIN users u ON lr.user_id = u.id
|
||||||
|
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
||||||
|
$whereClause
|
||||||
|
ORDER BY lr.created_at DESC
|
||||||
|
LIMIT 200
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$requests = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse($requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POST Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Submit new leave request
|
||||||
|
*/
|
||||||
|
function handleSubmitRequest(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$leaveType = $input['leave_type'] ?? '';
|
||||||
|
$dateFrom = $input['date_from'] ?? '';
|
||||||
|
$dateTo = $input['date_to'] ?? '';
|
||||||
|
$notes = trim($input['notes'] ?? '');
|
||||||
|
|
||||||
|
if (!$leaveType || !$dateFrom || !$dateTo) {
|
||||||
|
errorResponse('Vyplňte všechna povinná pole');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
||||||
|
errorResponse('Neplatný typ nepřítomnosti');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
$from = new DateTime($dateFrom);
|
||||||
|
$to = new DateTime($dateTo);
|
||||||
|
if ($to < $from) {
|
||||||
|
errorResponse('Datum "do" nesmí být před datem "od"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate business days
|
||||||
|
$businessDays = calculateBusinessDays($dateFrom, $dateTo);
|
||||||
|
if ($businessDays === 0) {
|
||||||
|
errorResponse('Zvolené období neobsahuje žádné pracovní dny');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalHours = $businessDays * 8;
|
||||||
|
|
||||||
|
// Check vacation balance
|
||||||
|
if ($leaveType === 'vacation') {
|
||||||
|
$year = (int)$from->format('Y');
|
||||||
|
$balance = getLeaveBalanceForRequest($pdo, $userId, $year);
|
||||||
|
$pendingHours = getPendingVacationHours($pdo, $userId, $year);
|
||||||
|
$availableHours = $balance['vacation_remaining'] - $pendingHours;
|
||||||
|
|
||||||
|
if ($availableHours < $totalHours) {
|
||||||
|
errorResponse(
|
||||||
|
"Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h"
|
||||||
|
. " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h),"
|
||||||
|
. " požadujete: {$totalHours}h."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check overlapping requests
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT id FROM leave_requests
|
||||||
|
WHERE user_id = ? AND status IN ('pending', 'approved')
|
||||||
|
AND date_from <= ? AND date_to >= ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $dateTo, $dateFrom]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('Pro toto období již existuje žádost o nepřítomnost');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert request
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
|
||||||
|
");
|
||||||
|
$stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]);
|
||||||
|
|
||||||
|
$requestId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
AuditLog::logCreate('leave_request', $requestId, [
|
||||||
|
'leave_type' => $leaveType,
|
||||||
|
'date_from' => $dateFrom,
|
||||||
|
'date_to' => $dateTo,
|
||||||
|
'total_days' => $businessDays,
|
||||||
|
'total_hours' => $totalHours,
|
||||||
|
], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)");
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$employeeName = $stmt->fetchColumn() ?: 'Neznámý';
|
||||||
|
|
||||||
|
LeaveNotification::notifyNewRequest([
|
||||||
|
'leave_type' => $leaveType,
|
||||||
|
'date_from' => $dateFrom,
|
||||||
|
'date_to' => $dateTo,
|
||||||
|
'total_days' => $businessDays,
|
||||||
|
'total_hours' => $totalHours,
|
||||||
|
'notes' => $notes,
|
||||||
|
], $employeeName);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
error_log('Leave notification error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Cancel own pending request
|
||||||
|
*/
|
||||||
|
function handleCancelRequest(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$requestId = (int)($input['request_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$requestId) {
|
||||||
|
errorResponse('ID žádosti je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?');
|
||||||
|
$stmt->execute([$requestId, $userId]);
|
||||||
|
$request = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$request) {
|
||||||
|
errorResponse('Žádost nebyla nalezena');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request['status'] !== 'pending') {
|
||||||
|
errorResponse('Lze zrušit pouze čekající žádosti');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?");
|
||||||
|
$stmt->execute([$requestId]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'leave_request',
|
||||||
|
$requestId,
|
||||||
|
['status' => 'pending'],
|
||||||
|
['status' => 'cancelled'],
|
||||||
|
'Žádost o nepřítomnost zrušena zaměstnancem'
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Žádost byla zrušena');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Approve a leave request
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$requestId = (int)($input['request_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$requestId) {
|
||||||
|
errorResponse('ID žádosti je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
||||||
|
$stmt->execute([$requestId]);
|
||||||
|
$request = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$request) {
|
||||||
|
errorResponse('Žádost nebyla nalezena');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request['status'] !== 'pending') {
|
||||||
|
errorResponse('Lze schválit pouze čekající žádosti');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
||||||
|
errorResponse('Nemůžete schválit vlastní žádost', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check vacation balance
|
||||||
|
if ($request['leave_type'] === 'vacation') {
|
||||||
|
$year = (int)date('Y', strtotime($request['date_from']));
|
||||||
|
$balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year);
|
||||||
|
|
||||||
|
if ($balance['vacation_remaining'] < (float)$request['total_hours']) {
|
||||||
|
errorResponse(
|
||||||
|
"Zaměstnanec nemá dostatek hodin dovolené."
|
||||||
|
. " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin transaction
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create attendance records for each business day
|
||||||
|
$start = new DateTime($request['date_from']);
|
||||||
|
$end = new DateTime($request['date_to']);
|
||||||
|
$end->modify('+1 day');
|
||||||
|
|
||||||
|
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
||||||
|
$insertStmt = $pdo->prepare('
|
||||||
|
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||||
|
VALUES (?, ?, ?, 8, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
$leaveNote = "Schválená žádost #$requestId";
|
||||||
|
$totalBusinessDays = 0;
|
||||||
|
|
||||||
|
foreach ($period as $date) {
|
||||||
|
$dayOfWeek = (int)$date->format('N');
|
||||||
|
if ($dayOfWeek <= 5) {
|
||||||
|
$shiftDate = $date->format('Y-m-d');
|
||||||
|
$insertStmt->execute([
|
||||||
|
$request['user_id'],
|
||||||
|
$shiftDate,
|
||||||
|
$request['leave_type'],
|
||||||
|
$leaveNote,
|
||||||
|
]);
|
||||||
|
$totalBusinessDays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update leave balance ONCE with total hours (was N queries, one per day)
|
||||||
|
if ($totalBusinessDays > 0) {
|
||||||
|
updateLeaveBalance(
|
||||||
|
$pdo,
|
||||||
|
(int)$request['user_id'],
|
||||||
|
$request['date_from'],
|
||||||
|
$request['leave_type'],
|
||||||
|
(float)($totalBusinessDays * 8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update request status
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE leave_requests
|
||||||
|
SET status = 'approved', reviewer_id = ?, reviewed_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$reviewerId, $requestId]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'leave_request',
|
||||||
|
$requestId,
|
||||||
|
['status' => 'pending'],
|
||||||
|
['status' => 'approved', 'reviewer_id' => $reviewerId],
|
||||||
|
'Žádost o nepřítomnost schválena'
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Žádost byla schválena');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
error_log('Approve request error: ' . $e->getMessage());
|
||||||
|
errorResponse('Chyba při schvalování žádosti', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Reject a leave request
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$requestId = (int)($input['request_id'] ?? 0);
|
||||||
|
$note = trim($input['note'] ?? '');
|
||||||
|
|
||||||
|
if (!$requestId) {
|
||||||
|
errorResponse('ID žádosti je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$note) {
|
||||||
|
errorResponse('Důvod zamítnutí je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
||||||
|
$stmt->execute([$requestId]);
|
||||||
|
$request = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$request) {
|
||||||
|
errorResponse('Žádost nebyla nalezena');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request['status'] !== 'pending') {
|
||||||
|
errorResponse('Lze zamítnout pouze čekající žádosti');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
||||||
|
errorResponse('Nemůžete zamítnout vlastní žádost', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
UPDATE leave_requests
|
||||||
|
SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$reviewerId, $note, $requestId]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'leave_request',
|
||||||
|
$requestId,
|
||||||
|
['status' => 'pending'],
|
||||||
|
['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note],
|
||||||
|
"Žádost o nepřítomnost zamítnuta: $note"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Žádost byla zamítnuta');
|
||||||
|
}
|
||||||
588
api/admin/handlers/offers-handlers.php
Normal file
588
api/admin/handlers/offers-handlers.php
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function handleGetList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$sort = $_GET['sort'] ?? 'created_at';
|
||||||
|
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||||
|
|
||||||
|
$sortMap = [
|
||||||
|
'Date' => 'q.created_at',
|
||||||
|
'CreatedAt' => 'q.created_at',
|
||||||
|
'created_at' => 'q.created_at',
|
||||||
|
'QuotationNumber' => 'q.quotation_number',
|
||||||
|
'quotation_number' => 'q.quotation_number',
|
||||||
|
'ProjectCode' => 'q.project_code',
|
||||||
|
'project_code' => 'q.project_code',
|
||||||
|
'ValidUntil' => 'q.valid_until',
|
||||||
|
'valid_until' => 'q.valid_until',
|
||||||
|
'Currency' => 'q.currency',
|
||||||
|
'currency' => 'q.currency',
|
||||||
|
];
|
||||||
|
if (!isset($sortMap[$sort])) {
|
||||||
|
errorResponse('Neplatný parametr řazení', 400);
|
||||||
|
}
|
||||||
|
$sortCol = $sortMap[$sort];
|
||||||
|
|
||||||
|
$where = 'WHERE 1=1';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search = mb_substr($search, 0, 100);
|
||||||
|
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
||||||
|
$searchParam = "%{$search}%";
|
||||||
|
$params = [$searchParam, $searchParam, $searchParam];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Celkovy pocet pro pagination
|
||||||
|
$countSql = "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM quotations q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
$where
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($countSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
|
||||||
|
q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate,
|
||||||
|
q.customer_id, q.order_id, q.status,
|
||||||
|
c.name as customer_name,
|
||||||
|
(SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0)
|
||||||
|
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
|
||||||
|
FROM quotations q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
$where
|
||||||
|
ORDER BY $sortCol $order
|
||||||
|
LIMIT $perPage OFFSET $offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$quotations = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'quotations' => $quotations,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT q.*, c.name as customer_name
|
||||||
|
FROM quotations q
|
||||||
|
LEFT JOIN customers c ON q.customer_id = c.id
|
||||||
|
WHERE q.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$quotation) {
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM quotation_items
|
||||||
|
WHERE quotation_id = ?
|
||||||
|
ORDER BY position
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation['items'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get scope sections
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM scope_sections
|
||||||
|
WHERE quotation_id = ?
|
||||||
|
ORDER BY position
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation['sections'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get customer
|
||||||
|
if ($quotation['customer_id']) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
||||||
|
FROM customers WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$quotation['customer_id']]);
|
||||||
|
$quotation['customer'] = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get linked order info
|
||||||
|
if ($quotation['order_id']) {
|
||||||
|
$stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?');
|
||||||
|
$stmt->execute([$quotation['order_id']]);
|
||||||
|
$quotation['order'] = $stmt->fetch() ?: null;
|
||||||
|
} else {
|
||||||
|
$quotation['order'] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse($quotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetNextNumber(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
||||||
|
if (!$settings) {
|
||||||
|
errorResponse('Nastavení firmy nenalezeno');
|
||||||
|
}
|
||||||
|
|
||||||
|
$year = date('Y');
|
||||||
|
$prefix = $settings['quotation_prefix'] ?: 'N';
|
||||||
|
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
||||||
|
|
||||||
|
$formatted = sprintf('%s/%s/%03d', $year, $prefix, $number);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'number' => $formatted,
|
||||||
|
'raw_number' => $number,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int
|
||||||
|
{
|
||||||
|
$likePattern = "{$year}/{$prefix}/%";
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0)
|
||||||
|
FROM quotations
|
||||||
|
WHERE quotation_number LIKE ?
|
||||||
|
");
|
||||||
|
$stmt->execute([$likePattern]);
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateNextNumber(PDO $pdo): string
|
||||||
|
{
|
||||||
|
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
||||||
|
|
||||||
|
$year = date('Y');
|
||||||
|
$prefix = $settings['quotation_prefix'] ?: 'N';
|
||||||
|
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
||||||
|
|
||||||
|
return sprintf('%s/%s/%03d', $year, $prefix, $number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $q */
|
||||||
|
function validateQuotationInput(array $q): void
|
||||||
|
{
|
||||||
|
if (empty($q['customer_id'])) {
|
||||||
|
errorResponse('Vyberte zákazníka');
|
||||||
|
}
|
||||||
|
if (empty($q['created_at'])) {
|
||||||
|
errorResponse('Zadejte datum vytvoření');
|
||||||
|
}
|
||||||
|
if (empty($q['valid_until'])) {
|
||||||
|
errorResponse('Zadejte datum platnosti');
|
||||||
|
}
|
||||||
|
if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) {
|
||||||
|
errorResponse('Datum platnosti nesmí být před datem vytvoření');
|
||||||
|
}
|
||||||
|
if (empty($q['currency'])) {
|
||||||
|
errorResponse('Vyberte měnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace formatu dat
|
||||||
|
foreach (['created_at', 'valid_until'] as $dateField) {
|
||||||
|
if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) {
|
||||||
|
errorResponse("Neplatný formát data: $dateField");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validace meny a jazyka
|
||||||
|
if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) {
|
||||||
|
errorResponse('Neplatný jazyk');
|
||||||
|
}
|
||||||
|
// Validace DPH
|
||||||
|
if (isset($q['vat_rate'])) {
|
||||||
|
$rate = floatval($q['vat_rate']);
|
||||||
|
if ($rate < 0 || $rate > 100) {
|
||||||
|
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Delkove limity
|
||||||
|
if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) {
|
||||||
|
errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateOffer(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
$quotation = $input['quotation'] ?? $input;
|
||||||
|
$items = $input['items'] ?? [];
|
||||||
|
$sections = $input['sections'] ?? [];
|
||||||
|
|
||||||
|
validateQuotationInput($quotation);
|
||||||
|
|
||||||
|
// Serialize number generation across concurrent requests
|
||||||
|
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
||||||
|
if (!$locked) {
|
||||||
|
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$quotationNumber = generateNextNumber($pdo);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO quotations (
|
||||||
|
quotation_number, project_code, customer_id, created_at, valid_until,
|
||||||
|
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||||
|
scope_title, scope_description, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$quotationNumber,
|
||||||
|
$quotation['project_code'] ?? '',
|
||||||
|
$quotation['customer_id'] ? (int)$quotation['customer_id'] : null,
|
||||||
|
$quotation['created_at'] ?? date('Y-m-d H:i:s'),
|
||||||
|
$quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')),
|
||||||
|
$quotation['currency'] ?? 'EUR',
|
||||||
|
$quotation['language'] ?? 'EN',
|
||||||
|
$quotation['vat_rate'] ?? 21,
|
||||||
|
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0,
|
||||||
|
$quotation['exchange_rate'] ?? null,
|
||||||
|
$quotation['scope_title'] ?? '',
|
||||||
|
$quotation['scope_description'] ?? '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$quotationId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
saveItems($pdo, $quotationId, $items);
|
||||||
|
saveSections($pdo, $quotationId, $sections);
|
||||||
|
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||||
|
|
||||||
|
AuditLog::logCreate('offers_quotation', $quotationId, [
|
||||||
|
'quotation_number' => $quotationNumber,
|
||||||
|
'project_code' => $quotation['project_code'] ?? '',
|
||||||
|
], "Vytvořena nabídka '$quotationNumber'");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'id' => $quotationId,
|
||||||
|
'number' => $quotationNumber,
|
||||||
|
], 'Nabídka byla vytvořena');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateOffer(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$existing) {
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($existing['status'] === 'invalidated') {
|
||||||
|
errorResponse('Zneplatněnou nabídku nelze upravovat', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$quotation = $input['quotation'] ?? $input;
|
||||||
|
$items = $input['items'] ?? [];
|
||||||
|
$sections = $input['sections'] ?? [];
|
||||||
|
|
||||||
|
validateQuotationInput($quotation);
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE quotations SET
|
||||||
|
project_code = ?,
|
||||||
|
customer_id = ?,
|
||||||
|
created_at = ?,
|
||||||
|
valid_until = ?,
|
||||||
|
currency = ?,
|
||||||
|
language = ?,
|
||||||
|
vat_rate = ?,
|
||||||
|
apply_vat = ?,
|
||||||
|
exchange_rate = ?,
|
||||||
|
scope_title = ?,
|
||||||
|
scope_description = ?,
|
||||||
|
modified_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$quotation['project_code'] ?? $existing['project_code'],
|
||||||
|
isset($quotation['customer_id'])
|
||||||
|
? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null)
|
||||||
|
: $existing['customer_id'],
|
||||||
|
$quotation['created_at'] ?? $existing['created_at'],
|
||||||
|
$quotation['valid_until'] ?? $existing['valid_until'],
|
||||||
|
$quotation['currency'] ?? $existing['currency'],
|
||||||
|
$quotation['language'] ?? $existing['language'],
|
||||||
|
$quotation['vat_rate'] ?? $existing['vat_rate'],
|
||||||
|
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'],
|
||||||
|
array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'],
|
||||||
|
$quotation['scope_title'] ?? $existing['scope_title'],
|
||||||
|
$quotation['scope_description'] ?? $existing['scope_description'],
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Replace items
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
saveItems($pdo, $id, $items);
|
||||||
|
|
||||||
|
// Replace sections
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
saveSections($pdo, $id, $sections);
|
||||||
|
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'offers_quotation',
|
||||||
|
$id,
|
||||||
|
['quotation_number' => $existing['quotation_number']],
|
||||||
|
['project_code' => $quotation['project_code'] ?? $existing['project_code']],
|
||||||
|
"Upravena nabídka '{$existing['quotation_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Nabídka byla aktualizována');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDuplicate(PDO $pdo, int $sourceId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$sourceId]);
|
||||||
|
$source = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$source) {
|
||||||
|
errorResponse('Zdrojová nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$sourceId]);
|
||||||
|
$sourceItems = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$sourceId]);
|
||||||
|
$sourceSections = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
||||||
|
if (!$locked) {
|
||||||
|
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$newNumber = generateNextNumber($pdo);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO quotations (
|
||||||
|
quotation_number, project_code, customer_id, created_at, valid_until,
|
||||||
|
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||||
|
scope_title, scope_description, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$newNumber,
|
||||||
|
$source['project_code'],
|
||||||
|
$source['customer_id'],
|
||||||
|
date('Y-m-d H:i:s'),
|
||||||
|
date('Y-m-d H:i:s', strtotime('+30 days')),
|
||||||
|
$source['currency'],
|
||||||
|
$source['language'],
|
||||||
|
$source['vat_rate'],
|
||||||
|
$source['apply_vat'],
|
||||||
|
$source['exchange_rate'],
|
||||||
|
$source['scope_title'],
|
||||||
|
$source['scope_description'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
$items = array_map(function ($item) {
|
||||||
|
return [
|
||||||
|
'description' => $item['description'],
|
||||||
|
'item_description' => $item['item_description'],
|
||||||
|
'quantity' => $item['quantity'],
|
||||||
|
'unit_price' => $item['unit_price'],
|
||||||
|
'is_included_in_total' => $item['is_included_in_total'],
|
||||||
|
'position' => $item['position'],
|
||||||
|
];
|
||||||
|
}, $sourceItems);
|
||||||
|
saveItems($pdo, $newId, $items);
|
||||||
|
|
||||||
|
$sections = array_map(function ($section) {
|
||||||
|
return [
|
||||||
|
'title' => $section['title'],
|
||||||
|
'title_cz' => $section['title_cz'],
|
||||||
|
'content' => $section['content'],
|
||||||
|
'position' => $section['position'],
|
||||||
|
];
|
||||||
|
}, $sourceSections);
|
||||||
|
saveSections($pdo, $newId, $sections);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||||
|
|
||||||
|
AuditLog::logCreate('offers_quotation', $newId, [
|
||||||
|
'quotation_number' => $newNumber,
|
||||||
|
'duplicated_from' => $source['quotation_number'],
|
||||||
|
], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'id' => $newId,
|
||||||
|
'number' => $newNumber,
|
||||||
|
], 'Nabídka byla duplikována');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInvalidateOffer(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$quotation) {
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quotation['status'] === 'invalidated') {
|
||||||
|
errorResponse('Nabídka je již zneplatněna', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quotation['order_id']) {
|
||||||
|
errorResponse('Nabídku s objednávkou nelze zneplatnit', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?');
|
||||||
|
$stmt->execute(['invalidated', $id]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'offers_quotation',
|
||||||
|
$id,
|
||||||
|
['status' => 'active'],
|
||||||
|
['status' => 'invalidated'],
|
||||||
|
"Zneplatněna nabídka '{$quotation['quotation_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Nabídka byla zneplatněna');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteQuotation(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$quotation = $stmt->fetch();
|
||||||
|
|
||||||
|
|
||||||
|
if (!$quotation) {
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logDelete('offers_quotation', $id, [
|
||||||
|
'quotation_number' => $quotation['quotation_number'],
|
||||||
|
], "Smazána nabídka '{$quotation['quotation_number']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Nabídka byla smazána');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $items */
|
||||||
|
function saveItems(PDO $pdo, int $quotationId, array $items): void
|
||||||
|
{
|
||||||
|
if (empty($items)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO quotation_items (
|
||||||
|
quotation_id, description, item_description, quantity, unit,
|
||||||
|
unit_price, is_included_in_total, position, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($items as $i => $item) {
|
||||||
|
$stmt->execute([
|
||||||
|
$quotationId,
|
||||||
|
$item['description'] ?? '',
|
||||||
|
$item['item_description'] ?? '',
|
||||||
|
$item['quantity'] ?? 1,
|
||||||
|
$item['unit'] ?? '',
|
||||||
|
$item['unit_price'] ?? 0,
|
||||||
|
isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1,
|
||||||
|
$item['position'] ?? ($i + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<array<string, mixed>> $sections */
|
||||||
|
function saveSections(PDO $pdo, int $quotationId, array $sections): void
|
||||||
|
{
|
||||||
|
if (empty($sections)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO scope_sections (
|
||||||
|
quotation_id, title, title_cz, content, position, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($sections as $i => $section) {
|
||||||
|
$stmt->execute([
|
||||||
|
$quotationId,
|
||||||
|
$section['title'] ?? '',
|
||||||
|
$section['title_cz'] ?? '',
|
||||||
|
$section['content'] ?? '',
|
||||||
|
$section['position'] ?? ($i + 1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
api/admin/handlers/offers-templates-handlers.php
Normal file
263
api/admin/handlers/offers-templates-handlers.php
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function handleGetItemTemplates(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('SELECT * FROM item_templates ORDER BY category, name');
|
||||||
|
successResponse(['templates' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveItemTemplate(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
if (empty($input['name'])) {
|
||||||
|
errorResponse('Název šablony je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = isset($input['id']) ? (int)$input['id'] : null;
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// Update
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE item_templates SET
|
||||||
|
name = ?, description = ?, default_price = ?, category = ?,
|
||||||
|
modified_at = NOW(), sync_version = sync_version + 1
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'],
|
||||||
|
$input['description'] ?? '',
|
||||||
|
$input['default_price'] ?? 0,
|
||||||
|
$input['category'] ?? '',
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
successResponse(null, 'Šablona byla aktualizována');
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
$uuid = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0x0fff) | 0x4000,
|
||||||
|
random_int(0, 0x3fff) | 0x8000,
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO item_templates (name, description, default_price, category, uuid, modified_at, sync_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW(), 1)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'],
|
||||||
|
$input['description'] ?? '',
|
||||||
|
$input['default_price'] ?? 0,
|
||||||
|
$input['category'] ?? '',
|
||||||
|
$uuid,
|
||||||
|
]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
AuditLog::logCreate(
|
||||||
|
'offers_item_template',
|
||||||
|
(int)$newId,
|
||||||
|
['name' => $input['name']],
|
||||||
|
"Vytvořena šablona položky '{$input['name']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(['id' => $newId], 'Šablona byla vytvořena');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteItemTemplate(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT name FROM item_templates WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$template = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$template) {
|
||||||
|
errorResponse('Šablona nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM item_templates WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
|
||||||
|
AuditLog::logDelete(
|
||||||
|
'offers_item_template',
|
||||||
|
$id,
|
||||||
|
['name' => $template['name']],
|
||||||
|
"Smazána šablona položky '{$template['name']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Šablona byla smazána');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Scope Templates ---
|
||||||
|
|
||||||
|
function handleGetScopeTemplates(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name');
|
||||||
|
successResponse(['templates' => $stmt->fetchAll()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetScopeDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$template = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$template) {
|
||||||
|
errorResponse('Šablona nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$template['sections'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse($template);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveScopeTemplate(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
if (empty($input['name'])) {
|
||||||
|
errorResponse('Název šablony je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = isset($input['id']) ? (int)$input['id'] : null;
|
||||||
|
$sections = $input['sections'] ?? [];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
if ($id) {
|
||||||
|
// Update template
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE scope_templates SET
|
||||||
|
name = ?,
|
||||||
|
title = ?,
|
||||||
|
description = ?,
|
||||||
|
modified_at = NOW(),
|
||||||
|
sync_version = sync_version + 1
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'],
|
||||||
|
$input['title'] ?? '',
|
||||||
|
$input['description'] ?? '',
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// Create template
|
||||||
|
$uuid = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0x0fff) | 0x4000,
|
||||||
|
random_int(0, 0x3fff) | 0x8000,
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff)
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO scope_templates (name, title, description, uuid, modified_at, sync_version)
|
||||||
|
VALUES (?, ?, ?, ?, NOW(), 1)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$input['name'],
|
||||||
|
$input['title'] ?? '',
|
||||||
|
$input['description'] ?? '',
|
||||||
|
$uuid,
|
||||||
|
]);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete existing sections and re-insert
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO scope_template_sections
|
||||||
|
(scope_template_id, title, title_cz, content, position, uuid, modified_at, sync_version)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW(), 1)
|
||||||
|
');
|
||||||
|
|
||||||
|
foreach ($sections as $i => $section) {
|
||||||
|
$sectionUuid = sprintf(
|
||||||
|
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0x0fff) | 0x4000,
|
||||||
|
random_int(0, 0x3fff) | 0x8000,
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff),
|
||||||
|
random_int(0, 0xffff)
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
$id,
|
||||||
|
$section['title'] ?? '',
|
||||||
|
$section['title_cz'] ?? '',
|
||||||
|
$section['content'] ?? '',
|
||||||
|
$i + 1,
|
||||||
|
$sectionUuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logCreate(
|
||||||
|
'offers_scope_template',
|
||||||
|
$id,
|
||||||
|
['name' => $input['name']],
|
||||||
|
"Uložena šablona rozsahu '{$input['name']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(['id' => $id], 'Šablona rozsahu byla uložena');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteScopeTemplate(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT name FROM scope_templates WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$template = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$template) {
|
||||||
|
errorResponse('Šablona nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Delete sections
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// Delete template
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM scope_templates WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logDelete(
|
||||||
|
'offers_scope_template',
|
||||||
|
$id,
|
||||||
|
['name' => $template['name']],
|
||||||
|
"Smazána šablona rozsahu '{$template['name']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Šablona rozsahu byla smazána');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
530
api/admin/handlers/orders-handlers.php
Normal file
530
api/admin/handlers/orders-handlers.php
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
function getValidTransitions(string $currentStatus): array
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'prijata' => ['v_realizaci', 'stornovana'],
|
||||||
|
'v_realizaci' => ['dokoncena', 'stornovana'],
|
||||||
|
'dokoncena' => [],
|
||||||
|
'stornovana' => [],
|
||||||
|
];
|
||||||
|
return $map[$currentStatus] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Number generation ---
|
||||||
|
|
||||||
|
function generateOrderNumber(PDO $pdo): string
|
||||||
|
{
|
||||||
|
return generateSharedNumber($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Handlers ---
|
||||||
|
|
||||||
|
function handleGetList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$sort = $_GET['sort'] ?? 'created_at';
|
||||||
|
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||||
|
|
||||||
|
$sortMap = [
|
||||||
|
'OrderNumber' => 'o.order_number',
|
||||||
|
'order_number' => 'o.order_number',
|
||||||
|
'CreatedAt' => 'o.created_at',
|
||||||
|
'created_at' => 'o.created_at',
|
||||||
|
'Status' => 'o.status',
|
||||||
|
'status' => 'o.status',
|
||||||
|
'Currency' => 'o.currency',
|
||||||
|
'currency' => 'o.currency',
|
||||||
|
];
|
||||||
|
if (!isset($sortMap[$sort])) {
|
||||||
|
errorResponse('Neplatný parametr řazení', 400);
|
||||||
|
}
|
||||||
|
$sortCol = $sortMap[$sort];
|
||||||
|
|
||||||
|
$where = 'WHERE 1=1';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search = mb_substr($search, 0, 100);
|
||||||
|
$where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
||||||
|
$searchParam = "%{$search}%";
|
||||||
|
$params = [$searchParam, $searchParam, $searchParam, $searchParam];
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||||
|
LEFT JOIN customers c ON o.customer_id = c.id
|
||||||
|
$where
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($countSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
|
||||||
|
o.created_at, o.apply_vat, o.vat_rate,
|
||||||
|
q.quotation_number, q.project_code,
|
||||||
|
c.name as customer_name,
|
||||||
|
(SELECT COALESCE(SUM(CASE WHEN oi.is_included_in_total THEN oi.quantity * oi.unit_price ELSE 0 END), 0)
|
||||||
|
FROM order_items oi WHERE oi.order_id = o.id) as total,
|
||||||
|
(SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id,
|
||||||
|
(SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||||
|
LEFT JOIN customers c ON o.customer_id = c.id
|
||||||
|
$where
|
||||||
|
ORDER BY $sortCol $order
|
||||||
|
LIMIT $perPage OFFSET $offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$orders = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'orders' => $orders,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
// BLOB vynechany - stahuje se pres action=attachment
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT o.id, o.order_number, o.customer_order_number, o.attachment_name,
|
||||||
|
o.quotation_id, o.customer_id, o.status, o.currency, o.language,
|
||||||
|
o.vat_rate, o.apply_vat, o.exchange_rate, o.scope_title, o.scope_description,
|
||||||
|
o.notes, o.created_at, o.modified_at,
|
||||||
|
q.quotation_number, q.project_code,
|
||||||
|
c.name as customer_name
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN quotations q ON o.quotation_id = q.id
|
||||||
|
LEFT JOIN customers c ON o.customer_id = c.id
|
||||||
|
WHERE o.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
errorResponse('Objednávka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order['items'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get sections
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order['sections'] = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get customer
|
||||||
|
if ($order['customer_id']) {
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, name, company_id, vat_id, street, city,
|
||||||
|
postal_code, country, custom_fields
|
||||||
|
FROM customers WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$order['customer_id']]);
|
||||||
|
$order['customer'] = $stmt->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get linked project
|
||||||
|
$stmt = $pdo->prepare('SELECT id, project_number, name, status FROM projects WHERE order_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order['project'] = $stmt->fetch() ?: null;
|
||||||
|
|
||||||
|
// Get linked invoice
|
||||||
|
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order['invoice'] = $stmt->fetch() ?: null;
|
||||||
|
|
||||||
|
// Valid transitions
|
||||||
|
$order['valid_transitions'] = getValidTransitions($order['status']);
|
||||||
|
|
||||||
|
successResponse($order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetAttachment(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT attachment_data, attachment_name FROM orders WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row || !$row['attachment_data']) {
|
||||||
|
errorResponse('Příloha nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->buffer($row['attachment_data']);
|
||||||
|
if ($mime !== 'application/pdf') {
|
||||||
|
errorResponse('Příloha není platný PDF soubor', 415);
|
||||||
|
}
|
||||||
|
|
||||||
|
header_remove('Content-Type');
|
||||||
|
header('Content-Type: application/pdf');
|
||||||
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['attachment_name'] ?: 'priloha.pdf'));
|
||||||
|
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
||||||
|
header('Content-Length: ' . strlen($row['attachment_data']));
|
||||||
|
echo $row['attachment_data'];
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateOrder(PDO $pdo): void
|
||||||
|
{
|
||||||
|
// Podporuje JSON i FormData (kvuli nahravani prilohy)
|
||||||
|
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
||||||
|
if (str_contains($contentType, 'multipart/form-data')) {
|
||||||
|
$quotationId = (int)($_POST['quotationId'] ?? 0);
|
||||||
|
$customerOrderNumber = trim($_POST['customerOrderNumber'] ?? '');
|
||||||
|
} else {
|
||||||
|
$input = getJsonInput();
|
||||||
|
$quotationId = (int)($input['quotationId'] ?? 0);
|
||||||
|
$customerOrderNumber = trim($input['customerOrderNumber'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$quotationId) {
|
||||||
|
errorResponse('ID nabídky je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($customerOrderNumber === '') {
|
||||||
|
errorResponse('Číslo objednávky zákazníka je povinné');
|
||||||
|
}
|
||||||
|
if (mb_strlen($customerOrderNumber) > 100) {
|
||||||
|
errorResponse('Číslo objednávky zákazníka je příliš dlouhé (max 100 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace prilohy
|
||||||
|
$attachmentData = null;
|
||||||
|
$attachmentName = null;
|
||||||
|
if (!empty($_FILES['attachment']['tmp_name'])) {
|
||||||
|
$file = $_FILES['attachment'];
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
errorResponse('Chyba při nahrávání souboru');
|
||||||
|
}
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->file($file['tmp_name']);
|
||||||
|
if ($mime !== 'application/pdf') {
|
||||||
|
errorResponse('Příloha musí být ve formátu PDF');
|
||||||
|
}
|
||||||
|
if ($file['size'] > 10 * 1024 * 1024) {
|
||||||
|
errorResponse('Příloha nesmí být větší než 10 MB');
|
||||||
|
}
|
||||||
|
$attachmentData = file_get_contents($file['tmp_name']);
|
||||||
|
$attachmentName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name']));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify quotation exists and has no order yet
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
||||||
|
$stmt->execute([$quotationId]);
|
||||||
|
$quotation = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$quotation) {
|
||||||
|
errorResponse('Nabídka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quotation['order_id']) {
|
||||||
|
errorResponse('Tato nabídka již má objednávku');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get quotation items and sections
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$quotationId]);
|
||||||
|
$quotationItems = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
||||||
|
$stmt->execute([$quotationId]);
|
||||||
|
$quotationSections = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Lock for concurrent number generation
|
||||||
|
$locked = $pdo->query("SELECT GET_LOCK('boha_order_number', 5)")->fetchColumn();
|
||||||
|
if (!$locked) {
|
||||||
|
errorResponse('Nepodařilo se získat zámek pro číslo objednávky, zkuste to znovu', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$orderNumber = generateOrderNumber($pdo);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO orders (
|
||||||
|
order_number, customer_order_number, attachment_data, attachment_name,
|
||||||
|
quotation_id, customer_id, status,
|
||||||
|
currency, language, vat_rate, apply_vat, exchange_rate,
|
||||||
|
scope_title, scope_description, created_at, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, 'prijata', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$orderNumber,
|
||||||
|
$customerOrderNumber,
|
||||||
|
$attachmentData,
|
||||||
|
$attachmentName,
|
||||||
|
$quotationId,
|
||||||
|
$quotation['customer_id'],
|
||||||
|
$quotation['currency'] ?? 'EUR',
|
||||||
|
$quotation['language'] ?? 'EN',
|
||||||
|
$quotation['vat_rate'] ?? 0,
|
||||||
|
$quotation['apply_vat'] ?? 0,
|
||||||
|
$quotation['exchange_rate'],
|
||||||
|
$quotation['scope_title'] ?? '',
|
||||||
|
$quotation['scope_description'] ?? '',
|
||||||
|
]);
|
||||||
|
$orderId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Copy items
|
||||||
|
if (!empty($quotationItems)) {
|
||||||
|
$itemStmt = $pdo->prepare('
|
||||||
|
INSERT INTO order_items (
|
||||||
|
order_id, description, item_description, quantity, unit,
|
||||||
|
unit_price, is_included_in_total, position, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
foreach ($quotationItems as $item) {
|
||||||
|
$itemStmt->execute([
|
||||||
|
$orderId,
|
||||||
|
$item['description'] ?? '',
|
||||||
|
$item['item_description'] ?? '',
|
||||||
|
$item['quantity'] ?? 1,
|
||||||
|
$item['unit'] ?? '',
|
||||||
|
$item['unit_price'] ?? 0,
|
||||||
|
$item['is_included_in_total'] ?? 1,
|
||||||
|
$item['position'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy sections
|
||||||
|
if (!empty($quotationSections)) {
|
||||||
|
$sectionStmt = $pdo->prepare('
|
||||||
|
INSERT INTO order_sections (
|
||||||
|
order_id, title, title_cz, content, position, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
foreach ($quotationSections as $section) {
|
||||||
|
$sectionStmt->execute([
|
||||||
|
$orderId,
|
||||||
|
$section['title'] ?? '',
|
||||||
|
$section['title_cz'] ?? '',
|
||||||
|
$section['content'] ?? '',
|
||||||
|
$section['position'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create project with same number
|
||||||
|
$projectName = $quotation['project_code'] ?: ($quotation['customer_name'] ?? 'Projekt ' . $orderNumber);
|
||||||
|
// Need customer name
|
||||||
|
if (!$quotation['project_code'] && $quotation['customer_id']) {
|
||||||
|
$custStmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?');
|
||||||
|
$custStmt->execute([$quotation['customer_id']]);
|
||||||
|
$custName = $custStmt->fetchColumn();
|
||||||
|
if ($custName) {
|
||||||
|
$projectName = $custName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_number, name, customer_id, quotation_id, order_id,
|
||||||
|
status, start_date, created_at, modified_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'aktivni', CURDATE(), NOW(), NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$orderNumber,
|
||||||
|
$projectName,
|
||||||
|
$quotation['customer_id'],
|
||||||
|
$quotationId,
|
||||||
|
$orderId,
|
||||||
|
]);
|
||||||
|
$projectId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Update quotation with back-reference
|
||||||
|
$stmt = $pdo->prepare('UPDATE quotations SET order_id = ?, modified_at = NOW() WHERE id = ?');
|
||||||
|
$stmt->execute([$orderId, $quotationId]);
|
||||||
|
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
||||||
|
|
||||||
|
AuditLog::logCreate('orders_order', $orderId, [
|
||||||
|
'order_number' => $orderNumber,
|
||||||
|
'quotation_number' => $quotation['quotation_number'],
|
||||||
|
'project_id' => $projectId,
|
||||||
|
], "Vytvořena objednávka '$orderNumber' z nabídky '{$quotation['quotation_number']}'");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'order_id' => $orderId,
|
||||||
|
'order_number' => $orderNumber,
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'project_number' => $orderNumber,
|
||||||
|
], 'Objednávka byla vytvořena');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateOrder(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
errorResponse('Objednávka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$newStatus = $input['status'] ?? null;
|
||||||
|
$notes = $input['notes'] ?? null;
|
||||||
|
$newOrderNumber = isset($input['order_number']) ? trim($input['order_number']) : null;
|
||||||
|
|
||||||
|
// Delkove limity
|
||||||
|
if ($notes !== null && mb_strlen($notes) > 5000) {
|
||||||
|
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||||
|
}
|
||||||
|
if ($newOrderNumber !== null && mb_strlen($newOrderNumber) > 50) {
|
||||||
|
errorResponse('Číslo objednávky je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate status transition
|
||||||
|
if ($newStatus && $newStatus !== $order['status']) {
|
||||||
|
$valid = getValidTransitions($order['status']);
|
||||||
|
if (!in_array($newStatus, $valid)) {
|
||||||
|
errorResponse("Neplatný přechod stavu z '{$order['status']}' na '$newStatus'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate order number uniqueness
|
||||||
|
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
||||||
|
if (empty($newOrderNumber)) {
|
||||||
|
errorResponse('Číslo objednávky nesmí být prázdné');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ? AND id != ?');
|
||||||
|
$stmt->execute([$newOrderNumber, $id]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('Toto číslo objednávky již existuje');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
||||||
|
$updates[] = 'order_number = ?';
|
||||||
|
$params[] = $newOrderNumber;
|
||||||
|
|
||||||
|
// Sync project number
|
||||||
|
$stmt = $pdo->prepare('UPDATE projects SET project_number = ?, modified_at = NOW() WHERE order_id = ?');
|
||||||
|
$stmt->execute([$newOrderNumber, $id]);
|
||||||
|
}
|
||||||
|
if ($newStatus !== null) {
|
||||||
|
$updates[] = 'status = ?';
|
||||||
|
$params[] = $newStatus;
|
||||||
|
}
|
||||||
|
if ($notes !== null) {
|
||||||
|
$updates[] = 'notes = ?';
|
||||||
|
$params[] = $notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($updates)) {
|
||||||
|
$updates[] = 'modified_at = NOW()';
|
||||||
|
$params[] = $id;
|
||||||
|
$sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync project status with order status
|
||||||
|
if ($newStatus && $newStatus !== $order['status']) {
|
||||||
|
$projectStatus = null;
|
||||||
|
if ($newStatus === 'stornovana') {
|
||||||
|
$projectStatus = 'zruseny';
|
||||||
|
} elseif ($newStatus === 'dokoncena') {
|
||||||
|
$projectStatus = 'dokonceny';
|
||||||
|
} elseif ($newStatus === 'v_realizaci') {
|
||||||
|
$projectStatus = 'aktivni';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($projectStatus) {
|
||||||
|
$stmt = $pdo->prepare('UPDATE projects SET status = ?, modified_at = NOW() WHERE order_id = ?');
|
||||||
|
$stmt->execute([$projectStatus, $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'orders_order',
|
||||||
|
$id,
|
||||||
|
['status' => $order['status'], 'notes' => $order['notes']],
|
||||||
|
['status' => $newStatus ?? $order['status'], 'notes' => $notes ?? $order['notes']],
|
||||||
|
"Upravena objednávka '{$order['order_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Objednávka byla aktualizována');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteOrder(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$order = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$order) {
|
||||||
|
errorResponse('Objednávka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Delete project linked to this order
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// Delete order items and sections
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM order_items WHERE order_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM order_sections WHERE order_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// Delete order
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM orders WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// Remove back-reference from quotation
|
||||||
|
$stmt = $pdo->prepare('UPDATE quotations SET order_id = NULL, modified_at = NOW() WHERE order_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logDelete('orders_order', $id, [
|
||||||
|
'order_number' => $order['order_number'],
|
||||||
|
'quotation_id' => $order['quotation_id'],
|
||||||
|
], "Smazána objednávka '{$order['order_number']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Objednávka byla smazána');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
423
api/admin/handlers/projects-handlers.php
Normal file
423
api/admin/handlers/projects-handlers.php
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function generateProjectNumber(PDO $pdo): string
|
||||||
|
{
|
||||||
|
return generateSharedNumber($pdo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetNextNumber(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$number = generateProjectNumber($pdo);
|
||||||
|
successResponse(['number' => $number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateProject(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$name = trim($input['name'] ?? '');
|
||||||
|
if (!$name) {
|
||||||
|
errorResponse('Název projektu je povinný');
|
||||||
|
}
|
||||||
|
if (mb_strlen($name) > 255) {
|
||||||
|
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$customerId = isset($input['customer_id']) ? (int)$input['customer_id'] : null;
|
||||||
|
if (!$customerId) {
|
||||||
|
errorResponse('Zákazník je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify customer exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
||||||
|
$stmt->execute([$customerId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Zákazník nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = $input['start_date'] ?? date('Y-m-d');
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
|
||||||
|
errorResponse('Neplatný formát data zahájení');
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectNumber = trim($input['project_number'] ?? '');
|
||||||
|
if ($projectNumber && mb_strlen($projectNumber) > 50) {
|
||||||
|
errorResponse('Číslo projektu je příliš dlouhé (max 50 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock for concurrent number generation
|
||||||
|
$locked = $pdo->query("SELECT GET_LOCK('boha_project_number', 5)")->fetchColumn();
|
||||||
|
if (!$locked) {
|
||||||
|
errorResponse('Nepodařilo se získat zámek pro číslo projektu, zkuste to znovu', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Generate or validate number
|
||||||
|
if (!$projectNumber) {
|
||||||
|
$projectNumber = generateProjectNumber($pdo);
|
||||||
|
} else {
|
||||||
|
// Validate uniqueness against both tables
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ?');
|
||||||
|
$stmt->execute([$projectNumber]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||||
|
errorResponse('Číslo projektu je již použito jako číslo objednávky');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM projects WHERE project_number = ?');
|
||||||
|
$stmt->execute([$projectNumber]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||||
|
errorResponse('Číslo projektu je již použito');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
INSERT INTO projects (
|
||||||
|
project_number, name, customer_id,
|
||||||
|
status, start_date, created_at, modified_at
|
||||||
|
) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW())
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$projectNumber,
|
||||||
|
$name,
|
||||||
|
$customerId,
|
||||||
|
$startDate,
|
||||||
|
]);
|
||||||
|
$projectId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||||
|
|
||||||
|
AuditLog::logCreate('projects_project', $projectId, [
|
||||||
|
'project_number' => $projectNumber,
|
||||||
|
'name' => $name,
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
], "Ručně vytvořen projekt '$projectNumber'");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'project_id' => $projectId,
|
||||||
|
'project_number' => $projectNumber,
|
||||||
|
], 'Projekt byl vytvořen');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteProject(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$project = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
errorResponse('Projekt nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only manually created projects (without order_id) can be deleted
|
||||||
|
if (!empty($project['order_id'])) {
|
||||||
|
errorResponse('Projekt propojený s objednávkou nelze smazat. Smažte objednávku.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
// Delete project notes
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE project_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
// Delete project
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM projects WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'projects_project',
|
||||||
|
$id,
|
||||||
|
['status' => $project['status']],
|
||||||
|
['status' => 'deleted'],
|
||||||
|
"Smazán ruční projekt '{$project['project_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Projekt byl smazán');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$sort = $_GET['sort'] ?? 'created_at';
|
||||||
|
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||||
|
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
||||||
|
|
||||||
|
$sortMap = [
|
||||||
|
'ProjectNumber' => 'p.project_number',
|
||||||
|
'project_number' => 'p.project_number',
|
||||||
|
'Name' => 'p.name',
|
||||||
|
'name' => 'p.name',
|
||||||
|
'Status' => 'p.status',
|
||||||
|
'status' => 'p.status',
|
||||||
|
'StartDate' => 'p.start_date',
|
||||||
|
'start_date' => 'p.start_date',
|
||||||
|
'EndDate' => 'p.end_date',
|
||||||
|
'end_date' => 'p.end_date',
|
||||||
|
'CreatedAt' => 'p.created_at',
|
||||||
|
'created_at' => 'p.created_at',
|
||||||
|
];
|
||||||
|
if (!isset($sortMap[$sort])) {
|
||||||
|
errorResponse('Neplatný parametr řazení', 400);
|
||||||
|
}
|
||||||
|
$sortCol = $sortMap[$sort];
|
||||||
|
|
||||||
|
$where = 'WHERE 1=1';
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search = mb_substr($search, 0, 100);
|
||||||
|
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
|
||||||
|
$searchParam = "%{$search}%";
|
||||||
|
$params = [$searchParam, $searchParam, $searchParam];
|
||||||
|
}
|
||||||
|
|
||||||
|
$countSql = "
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN customers c ON p.customer_id = c.id
|
||||||
|
LEFT JOIN orders o ON p.order_id = o.id
|
||||||
|
$where
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($countSql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$total = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date,
|
||||||
|
p.order_id, p.quotation_id, p.created_at,
|
||||||
|
c.name as customer_name,
|
||||||
|
o.order_number
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN customers c ON p.customer_id = c.id
|
||||||
|
LEFT JOIN orders o ON p.order_id = o.id
|
||||||
|
$where
|
||||||
|
ORDER BY $sortCol $order
|
||||||
|
LIMIT $perPage OFFSET $offset
|
||||||
|
";
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$projects = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'projects' => $projects,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT p.*,
|
||||||
|
c.name as customer_name,
|
||||||
|
o.order_number, o.status as order_status,
|
||||||
|
q.quotation_number
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN customers c ON p.customer_id = c.id
|
||||||
|
LEFT JOIN orders o ON p.order_id = o.id
|
||||||
|
LEFT JOIN quotations q ON p.quotation_id = q.id
|
||||||
|
WHERE p.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$project = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
errorResponse('Projekt nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse($project);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUpdateProject(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$project = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
errorResponse('Projekt nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
// Validace statusu
|
||||||
|
if (isset($input['status'])) {
|
||||||
|
$validStatuses = ['aktivni', 'dokonceny', 'zruseny'];
|
||||||
|
if (!in_array($input['status'], $validStatuses)) {
|
||||||
|
errorResponse('Neplatný stav projektu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validace dat
|
||||||
|
if (
|
||||||
|
isset($input['start_date'])
|
||||||
|
&& $input['start_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
||||||
|
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['start_date'])
|
||||||
|
) {
|
||||||
|
errorResponse('Neplatný formát data zahájení');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isset($input['end_date'])
|
||||||
|
&& $input['end_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
||||||
|
&& $input['end_date'] !== ''
|
||||||
|
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['end_date'])
|
||||||
|
) {
|
||||||
|
errorResponse('Neplatný formát data ukončení');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delkove limity
|
||||||
|
$name = $input['name'] ?? $project['name'];
|
||||||
|
if (mb_strlen($name) > 255) {
|
||||||
|
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
||||||
|
}
|
||||||
|
$notes = $input['notes'] ?? $project['notes'];
|
||||||
|
if ($notes !== null && mb_strlen($notes) > 5000) {
|
||||||
|
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE projects SET
|
||||||
|
name = ?,
|
||||||
|
status = ?,
|
||||||
|
start_date = ?,
|
||||||
|
end_date = ?,
|
||||||
|
notes = ?,
|
||||||
|
modified_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$name,
|
||||||
|
$input['status'] ?? $project['status'],
|
||||||
|
$input['start_date'] ?? $project['start_date'],
|
||||||
|
$input['end_date'] ?? $project['end_date'],
|
||||||
|
$notes,
|
||||||
|
$id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'projects_project',
|
||||||
|
$id,
|
||||||
|
['name' => $project['name'], 'status' => $project['status']],
|
||||||
|
['name' => $input['name'] ?? $project['name'], 'status' => $input['status'] ?? $project['status']],
|
||||||
|
"Upraven projekt '{$project['project_number']}'"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Projekt byl aktualizován');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGetNotes(PDO $pdo, int $projectId): void
|
||||||
|
{
|
||||||
|
// Verify project exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
||||||
|
$stmt->execute([$projectId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Projekt nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id, project_id, user_id, user_name, content, created_at
|
||||||
|
FROM project_notes
|
||||||
|
WHERE project_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute([$projectId]);
|
||||||
|
$notes = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse(['notes' => $notes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $authData */
|
||||||
|
function handleAddNote(PDO $pdo, int $projectId, array $authData): void
|
||||||
|
{
|
||||||
|
// Verify project exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
||||||
|
$stmt->execute([$projectId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Projekt nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$content = trim($input['content'] ?? '');
|
||||||
|
|
||||||
|
if (!$content) {
|
||||||
|
errorResponse('Text poznámky je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mb_strlen($content) > 5000) {
|
||||||
|
errorResponse('Poznámka je příliš dlouhá (max 5000 znaků)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$userName = $authData['user']['full_name'] ?? $authData['user']['username'] ?? 'Neznámý';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO project_notes (project_id, user_id, user_name, content, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, NOW())
|
||||||
|
');
|
||||||
|
$stmt->execute([$projectId, $authData['user_id'], $userName, $content]);
|
||||||
|
|
||||||
|
$noteId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Fetch the new note
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT id, project_id, user_id, user_name, content, created_at FROM project_notes WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$noteId]);
|
||||||
|
$note = $stmt->fetch();
|
||||||
|
|
||||||
|
successResponse(['note' => $note], 'Poznámka byla přidána');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $authData */
|
||||||
|
function handleDeleteNote(PDO $pdo, int $noteId, array $authData): void
|
||||||
|
{
|
||||||
|
// Only admins can delete notes
|
||||||
|
$isAdmin = $authData['user']['is_admin'] ?? false;
|
||||||
|
if (!$isAdmin) {
|
||||||
|
errorResponse('Pouze administrátoři mohou mazat poznámky', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT id, project_id, content FROM project_notes WHERE id = ?');
|
||||||
|
$stmt->execute([$noteId]);
|
||||||
|
$note = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$note) {
|
||||||
|
errorResponse('Poznámka nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE id = ?');
|
||||||
|
$stmt->execute([$noteId]);
|
||||||
|
|
||||||
|
successResponse(null, 'Poznámka byla smazána');
|
||||||
|
}
|
||||||
506
api/admin/handlers/received-invoices-handlers.php
Normal file
506
api/admin/handlers/received-invoices-handlers.php
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
function getAllowedMimes(): array
|
||||||
|
{
|
||||||
|
return ['application/pdf', 'image/jpeg', 'image/png'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stats ---
|
||||||
|
|
||||||
|
function handleGetStats(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||||
|
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||||
|
|
||||||
|
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
||||||
|
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
||||||
|
|
||||||
|
// Celkem v měsíci (issue_date)
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt
|
||||||
|
FROM received_invoices
|
||||||
|
WHERE issue_date BETWEEN ? AND ?
|
||||||
|
GROUP BY currency
|
||||||
|
');
|
||||||
|
$stmt->execute([$monthStart, $monthEnd]);
|
||||||
|
$monthRows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$totalAmounts = [];
|
||||||
|
$vatAmounts = [];
|
||||||
|
$czkItems = [];
|
||||||
|
$vatCzkItems = [];
|
||||||
|
$monthCount = 0;
|
||||||
|
|
||||||
|
foreach ($monthRows as $r) {
|
||||||
|
$totalAmounts[$r['currency']] = round((float) $r['total'], 2);
|
||||||
|
$vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2);
|
||||||
|
$monthCount += (int) $r['cnt'];
|
||||||
|
$czkItems[] = [
|
||||||
|
'amount' => round((float) $r['total'], 2),
|
||||||
|
'currency' => $r['currency'],
|
||||||
|
'date' => $monthStart,
|
||||||
|
];
|
||||||
|
$vatCzkItems[] = [
|
||||||
|
'amount' => round((float) $r['vat_total'], 2),
|
||||||
|
'currency' => $r['currency'],
|
||||||
|
'date' => $monthStart,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalArr = [];
|
||||||
|
foreach ($totalAmounts as $cur => $amt) {
|
||||||
|
$totalArr[] = ['amount' => $amt, 'currency' => $cur];
|
||||||
|
}
|
||||||
|
$vatArr = [];
|
||||||
|
foreach ($vatAmounts as $cur => $amt) {
|
||||||
|
$vatArr[] = ['amount' => $amt, 'currency' => $cur];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neuhrazeno celkově
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT currency, SUM(amount) as total, COUNT(*) as cnt
|
||||||
|
FROM received_invoices WHERE status = ?
|
||||||
|
GROUP BY currency
|
||||||
|
');
|
||||||
|
$stmt->execute(['unpaid']);
|
||||||
|
$unpaidRows = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$unpaidAmounts = [];
|
||||||
|
$unpaidCzkItems = [];
|
||||||
|
$unpaidCount = 0;
|
||||||
|
foreach ($unpaidRows as $r) {
|
||||||
|
$unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']];
|
||||||
|
$unpaidCount += (int) $r['cnt'];
|
||||||
|
$unpaidCzkItems[] = [
|
||||||
|
'amount' => round((float) $r['total'], 2),
|
||||||
|
'currency' => $r['currency'],
|
||||||
|
'date' => date('Y-m-d'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cnb = CnbRates::getInstance();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'total_month' => $totalArr,
|
||||||
|
'total_month_czk' => $cnb->sumToCzk($czkItems),
|
||||||
|
'vat_month' => $vatArr,
|
||||||
|
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
||||||
|
'unpaid' => $unpaidAmounts,
|
||||||
|
'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems),
|
||||||
|
'unpaid_count' => $unpaidCount,
|
||||||
|
'month_count' => $monthCount,
|
||||||
|
'month' => $month,
|
||||||
|
'year' => $year,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List ---
|
||||||
|
|
||||||
|
function handleGetList(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
||||||
|
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
||||||
|
$search = trim($_GET['search'] ?? '');
|
||||||
|
$sort = $_GET['sort'] ?? 'created_at';
|
||||||
|
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
||||||
|
|
||||||
|
$sortMap = [
|
||||||
|
'supplier_name' => 'supplier_name',
|
||||||
|
'invoice_number' => 'invoice_number',
|
||||||
|
'status' => 'status',
|
||||||
|
'issue_date' => 'issue_date',
|
||||||
|
'due_date' => 'due_date',
|
||||||
|
'amount' => 'amount',
|
||||||
|
'created_at' => 'created_at',
|
||||||
|
];
|
||||||
|
if (!isset($sortMap[$sort])) {
|
||||||
|
errorResponse('Neplatný parametr řazení', 400);
|
||||||
|
}
|
||||||
|
$sortCol = $sortMap[$sort];
|
||||||
|
|
||||||
|
$where = 'WHERE month = ? AND year = ?';
|
||||||
|
$params = [$month, $year];
|
||||||
|
|
||||||
|
if ($search) {
|
||||||
|
$search = mb_substr($search, 0, 100);
|
||||||
|
$where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)';
|
||||||
|
$searchParam = "%{$search}%";
|
||||||
|
$params[] = $searchParam;
|
||||||
|
$params[] = $searchParam;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT id, supplier_name, invoice_number, description,
|
||||||
|
amount, currency, vat_rate, vat_amount,
|
||||||
|
issue_date, due_date, paid_date, status,
|
||||||
|
file_name, file_mime, file_size, notes,
|
||||||
|
created_at, modified_at
|
||||||
|
FROM received_invoices
|
||||||
|
$where
|
||||||
|
ORDER BY $sortCol $order
|
||||||
|
";
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$invoices = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse(['invoices' => $invoices]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detail ---
|
||||||
|
|
||||||
|
function handleGetDetail(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT id, supplier_name, invoice_number, description,
|
||||||
|
amount, currency, vat_rate, vat_amount,
|
||||||
|
issue_date, due_date, paid_date, status,
|
||||||
|
file_name, file_mime, file_size, notes,
|
||||||
|
uploaded_by, created_at, modified_at
|
||||||
|
FROM received_invoices WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse($invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File streaming ---
|
||||||
|
|
||||||
|
function handleGetFile(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$row || !$row['file_data']) {
|
||||||
|
errorResponse('Soubor nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name']));
|
||||||
|
header('Content-Type: ' . $row['file_mime']);
|
||||||
|
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
||||||
|
header('Content-Length: ' . $row['file_size']);
|
||||||
|
header_remove('X-Content-Type-Options');
|
||||||
|
echo $row['file_data'];
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bulk upload ---
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $authData */
|
||||||
|
function handleBulkUpload(PDO $pdo, array $authData): void
|
||||||
|
{
|
||||||
|
$invoicesJson = $_POST['invoices'] ?? '[]';
|
||||||
|
$invoicesMeta = json_decode($invoicesJson, true);
|
||||||
|
|
||||||
|
if (!is_array($invoicesMeta)) {
|
||||||
|
errorResponse('Neplatná metadata');
|
||||||
|
}
|
||||||
|
if (count($invoicesMeta) === 0) {
|
||||||
|
errorResponse('Žádné faktury k nahrání');
|
||||||
|
}
|
||||||
|
if (count($invoicesMeta) > 20) {
|
||||||
|
errorResponse('Maximálně 20 faktur najednou');
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = $_FILES['files'] ?? [];
|
||||||
|
$fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0;
|
||||||
|
|
||||||
|
if ($fileCount !== count($invoicesMeta)) {
|
||||||
|
errorResponse('Počet souborů neodpovídá počtu metadat');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedMimes = getAllowedMimes();
|
||||||
|
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
||||||
|
$validVatRates = [0, 10, 12, 15, 21];
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$created = [];
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO received_invoices (
|
||||||
|
month, year, supplier_name, invoice_number, description,
|
||||||
|
amount, currency, vat_rate, vat_amount,
|
||||||
|
issue_date, due_date, status,
|
||||||
|
file_data, file_name, file_mime, file_size,
|
||||||
|
notes, uploaded_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
for ($i = 0; $i < $fileCount; $i++) {
|
||||||
|
$meta = $invoicesMeta[$i];
|
||||||
|
$tmpName = $files['tmp_name'][$i];
|
||||||
|
$fileError = $files['error'][$i];
|
||||||
|
$fileSize = $files['size'][$i];
|
||||||
|
$fileName = $files['name'][$i];
|
||||||
|
|
||||||
|
if ($fileError !== UPLOAD_ERR_OK) {
|
||||||
|
errorResponse("Chyba při nahrávání souboru #" . ($i + 1));
|
||||||
|
}
|
||||||
|
if ($fileSize > 10 * 1024 * 1024) {
|
||||||
|
errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$mime = $finfo->file($tmpName);
|
||||||
|
if (!in_array($mime, $allowedMimes)) {
|
||||||
|
errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)");
|
||||||
|
}
|
||||||
|
|
||||||
|
$supplierName = trim($meta['supplier_name'] ?? '');
|
||||||
|
if ($supplierName === '') {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný");
|
||||||
|
}
|
||||||
|
if (mb_strlen($supplierName) > 255) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý");
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = (float) ($meta['amount'] ?? 0);
|
||||||
|
if ($amount <= 0) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
$currency = trim($meta['currency'] ?? 'CZK');
|
||||||
|
if (!in_array($currency, $validCurrencies)) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": neplatná měna");
|
||||||
|
}
|
||||||
|
|
||||||
|
$vatRate = (float) ($meta['vat_rate'] ?? 21);
|
||||||
|
if (!in_array((int) $vatRate, $validVatRates)) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH");
|
||||||
|
}
|
||||||
|
|
||||||
|
$vatAmount = round($amount * $vatRate / 100, 2);
|
||||||
|
$invoiceNumber = trim($meta['invoice_number'] ?? '');
|
||||||
|
$description = trim($meta['description'] ?? '');
|
||||||
|
$issueDate = trim($meta['issue_date'] ?? '');
|
||||||
|
$dueDate = trim($meta['due_date'] ?? '');
|
||||||
|
$notes = trim($meta['notes'] ?? '');
|
||||||
|
|
||||||
|
// Validace dat
|
||||||
|
if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení");
|
||||||
|
}
|
||||||
|
if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Délkové limity
|
||||||
|
if (mb_strlen($invoiceNumber) > 100) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé");
|
||||||
|
}
|
||||||
|
if (mb_strlen($description) > 500) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý");
|
||||||
|
}
|
||||||
|
if (mb_strlen($notes) > 5000) {
|
||||||
|
errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Určit month/year z issue_date nebo aktuální
|
||||||
|
if ($issueDate) {
|
||||||
|
$dt = new DateTime($issueDate);
|
||||||
|
$month = (int) $dt->format('n');
|
||||||
|
$year = (int) $dt->format('Y');
|
||||||
|
} else {
|
||||||
|
$month = (int) date('n');
|
||||||
|
$year = (int) date('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileData = file_get_contents($tmpName);
|
||||||
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
|
||||||
|
|
||||||
|
$stmt->execute([
|
||||||
|
$month,
|
||||||
|
$year,
|
||||||
|
$supplierName,
|
||||||
|
$invoiceNumber ?: null,
|
||||||
|
$description ?: null,
|
||||||
|
$amount,
|
||||||
|
$currency,
|
||||||
|
$vatRate,
|
||||||
|
$vatAmount,
|
||||||
|
$issueDate ?: null,
|
||||||
|
$dueDate ?: null,
|
||||||
|
'unpaid',
|
||||||
|
$fileData,
|
||||||
|
$safeName,
|
||||||
|
$mime,
|
||||||
|
$fileSize,
|
||||||
|
$notes ?: null,
|
||||||
|
$authData['user_id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$created[] = (int) $pdo->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logCreate('received_invoices', $created[0], [
|
||||||
|
'count' => count($created),
|
||||||
|
'ids' => $created,
|
||||||
|
], 'Nahráno ' . count($created) . ' přijatých faktur');
|
||||||
|
|
||||||
|
successResponse(['ids' => $created], 'Faktury byly nahrány');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Update ---
|
||||||
|
|
||||||
|
function handleUpdateReceivedInvoice(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$updates = [];
|
||||||
|
$params = [];
|
||||||
|
|
||||||
|
$stringFields = [
|
||||||
|
'supplier_name' => 255,
|
||||||
|
'invoice_number' => 100,
|
||||||
|
'description' => 500,
|
||||||
|
'notes' => 5000,
|
||||||
|
];
|
||||||
|
foreach ($stringFields as $field => $maxLen) {
|
||||||
|
if (array_key_exists($field, $input)) {
|
||||||
|
$val = trim((string) $input[$field]);
|
||||||
|
if ($field === 'supplier_name' && $val === '') {
|
||||||
|
errorResponse('Dodavatel je povinný');
|
||||||
|
}
|
||||||
|
if (mb_strlen($val) > $maxLen) {
|
||||||
|
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
||||||
|
}
|
||||||
|
$updates[] = "$field = ?";
|
||||||
|
$params[] = $val ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('amount', $input)) {
|
||||||
|
$amount = (float) $input['amount'];
|
||||||
|
if ($amount <= 0) {
|
||||||
|
errorResponse('Částka musí být větší než 0');
|
||||||
|
}
|
||||||
|
$updates[] = 'amount = ?';
|
||||||
|
$params[] = $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('currency', $input)) {
|
||||||
|
if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
||||||
|
errorResponse('Neplatná měna');
|
||||||
|
}
|
||||||
|
$updates[] = 'currency = ?';
|
||||||
|
$params[] = $input['currency'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('vat_rate', $input)) {
|
||||||
|
$vatRate = (float) $input['vat_rate'];
|
||||||
|
if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) {
|
||||||
|
errorResponse('Neplatná sazba DPH');
|
||||||
|
}
|
||||||
|
$updates[] = 'vat_rate = ?';
|
||||||
|
$params[] = $vatRate;
|
||||||
|
|
||||||
|
$amount = (float) ($input['amount'] ?? $invoice['amount']);
|
||||||
|
$updates[] = 'vat_amount = ?';
|
||||||
|
$params[] = round($amount * $vatRate / 100, 2);
|
||||||
|
} elseif (array_key_exists('amount', $input)) {
|
||||||
|
$vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']);
|
||||||
|
$updates[] = 'vat_amount = ?';
|
||||||
|
$params[] = round((float) $input['amount'] * $vatRate / 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['issue_date', 'due_date'] as $dateField) {
|
||||||
|
if (array_key_exists($dateField, $input)) {
|
||||||
|
$val = trim((string) $input[$dateField]);
|
||||||
|
if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) {
|
||||||
|
errorResponse("Neplatný formát data: $dateField");
|
||||||
|
}
|
||||||
|
$updates[] = "$dateField = ?";
|
||||||
|
$params[] = $val ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualizace month/year pokud se změní issue_date
|
||||||
|
if (array_key_exists('issue_date', $input) && $input['issue_date']) {
|
||||||
|
$dt = new DateTime($input['issue_date']);
|
||||||
|
$updates[] = 'month = ?';
|
||||||
|
$params[] = (int) $dt->format('n');
|
||||||
|
$updates[] = 'year = ?';
|
||||||
|
$params[] = (int) $dt->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Změna stavu - pouze unpaid -> paid (jednosmerny prechod)
|
||||||
|
if (array_key_exists('status', $input)) {
|
||||||
|
$newStatus = $input['status'];
|
||||||
|
if (!in_array($newStatus, ['unpaid', 'paid'])) {
|
||||||
|
errorResponse('Neplatný stav');
|
||||||
|
}
|
||||||
|
if ($invoice['status'] === 'paid' && $newStatus !== 'paid') {
|
||||||
|
errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená');
|
||||||
|
}
|
||||||
|
if ($newStatus !== $invoice['status']) {
|
||||||
|
$updates[] = 'status = ?';
|
||||||
|
$params[] = $newStatus;
|
||||||
|
if ($newStatus === 'paid') {
|
||||||
|
$updates[] = 'paid_date = CURDATE()';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($updates)) {
|
||||||
|
errorResponse('Žádné změny k uložení');
|
||||||
|
}
|
||||||
|
|
||||||
|
$updates[] = 'modified_at = NOW()';
|
||||||
|
$params[] = $id;
|
||||||
|
$sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
||||||
|
$pdo->prepare($sql)->execute($params);
|
||||||
|
|
||||||
|
AuditLog::logUpdate(
|
||||||
|
'received_invoices',
|
||||||
|
$id,
|
||||||
|
['status' => $invoice['status']],
|
||||||
|
['status' => $input['status'] ?? $invoice['status']],
|
||||||
|
"Aktualizována přijatá faktura #{$id}"
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(null, 'Faktura byla aktualizována');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Delete ---
|
||||||
|
|
||||||
|
function handleDeleteReceivedInvoice(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$invoice = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$invoice) {
|
||||||
|
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]);
|
||||||
|
|
||||||
|
AuditLog::logDelete('received_invoices', $id, [
|
||||||
|
'supplier_name' => $invoice['supplier_name'],
|
||||||
|
'invoice_number' => $invoice['invoice_number'],
|
||||||
|
], "Smazána přijatá faktura #{$id}");
|
||||||
|
|
||||||
|
successResponse(null, 'Faktura byla smazána');
|
||||||
|
}
|
||||||
237
api/admin/handlers/roles-handlers.php
Normal file
237
api/admin/handlers/roles-handlers.php
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - List all roles with their permissions + all available permissions
|
||||||
|
*/
|
||||||
|
function handleGetRole(PDO $pdo): void
|
||||||
|
{
|
||||||
|
// Get all roles with user count (LEFT JOIN instead of correlated subquery)
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT r.*, COUNT(u.id) as user_count
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN users u ON u.role_id = r.id
|
||||||
|
GROUP BY r.id
|
||||||
|
ORDER BY r.id
|
||||||
|
');
|
||||||
|
$roles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Batch fetch all role-permission mappings in one query (was N+1)
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT rp.role_id, p.name
|
||||||
|
FROM role_permissions rp
|
||||||
|
JOIN permissions p ON p.id = rp.permission_id
|
||||||
|
');
|
||||||
|
$allRolePerms = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Group permissions by role_id
|
||||||
|
$permsByRole = [];
|
||||||
|
foreach ($allRolePerms as $rp) {
|
||||||
|
$permsByRole[$rp['role_id']][] = $rp['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roles as &$role) {
|
||||||
|
$role['permissions'] = $permsByRole[$role['id']] ?? [];
|
||||||
|
$role['permission_count'] = count($role['permissions']);
|
||||||
|
}
|
||||||
|
unset($role);
|
||||||
|
|
||||||
|
// Get all available permissions grouped by module
|
||||||
|
$stmt = $pdo->query('SELECT id, name, display_name, description FROM permissions ORDER BY id');
|
||||||
|
$allPermissions = $stmt->fetchAll();
|
||||||
|
|
||||||
|
$grouped = [];
|
||||||
|
foreach ($allPermissions as $perm) {
|
||||||
|
$parts = explode('.', $perm['name'], 2);
|
||||||
|
$module = $parts[0];
|
||||||
|
if (!isset($grouped[$module])) {
|
||||||
|
$grouped[$module] = [];
|
||||||
|
}
|
||||||
|
$grouped[$module][] = $perm;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'roles' => $roles,
|
||||||
|
'permissions' => $allPermissions,
|
||||||
|
'permission_groups' => $grouped,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Create new role
|
||||||
|
*/
|
||||||
|
function handleCreateRole(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$name = trim($input['name'] ?? '');
|
||||||
|
$displayName = trim($input['display_name'] ?? '');
|
||||||
|
$description = trim($input['description'] ?? '');
|
||||||
|
$permissions = $input['permissions'] ?? [];
|
||||||
|
|
||||||
|
if (!$name) {
|
||||||
|
errorResponse('Název role je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$displayName) {
|
||||||
|
errorResponse('Zobrazovaný název je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name format (slug)
|
||||||
|
if (!preg_match('/^[a-z0-9_-]+$/', $name)) {
|
||||||
|
errorResponse('Název role může obsahovat pouze malá písmena, čísla, pomlčky a podtržítka');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check uniqueness
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM roles WHERE name = ?');
|
||||||
|
$stmt->execute([$name]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('Role s tímto názvem již existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create role
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO roles (name, display_name, description)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$name, $displayName, $description ?: null]);
|
||||||
|
$newRoleId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Assign permissions
|
||||||
|
if (!empty($permissions)) {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT ?, id FROM permissions WHERE name = ?
|
||||||
|
');
|
||||||
|
foreach ($permissions as $permName) {
|
||||||
|
$stmt->execute([$newRoleId, $permName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logCreate('role', $newRoleId, [
|
||||||
|
'name' => $name,
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'permissions' => $permissions,
|
||||||
|
], "Vytvořena role '$displayName'");
|
||||||
|
|
||||||
|
successResponse(['id' => $newRoleId], 'Role byla vytvořena');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - Update role
|
||||||
|
*/
|
||||||
|
function handleUpdateRole(PDO $pdo, int $roleId): void
|
||||||
|
{
|
||||||
|
// Get existing role
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
$role = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$role) {
|
||||||
|
errorResponse('Role nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block editing admin role name
|
||||||
|
if ($role['name'] === 'admin') {
|
||||||
|
errorResponse('Roli administrátora nelze upravovat');
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$displayName = trim($input['display_name'] ?? $role['display_name']);
|
||||||
|
$description = trim($input['description'] ?? $role['description'] ?? '');
|
||||||
|
$permissions = $input['permissions'] ?? null;
|
||||||
|
|
||||||
|
if (!$displayName) {
|
||||||
|
errorResponse('Zobrazovaný název je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update role
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE roles SET display_name = ?, description = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$displayName, $description ?: null, $roleId]);
|
||||||
|
|
||||||
|
// Update permissions if provided
|
||||||
|
if ($permissions !== null) {
|
||||||
|
// Remove existing permissions
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM role_permissions WHERE role_id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
|
||||||
|
// Add new permissions
|
||||||
|
if (!empty($permissions)) {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT ?, id FROM permissions WHERE name = ?
|
||||||
|
');
|
||||||
|
foreach ($permissions as $permName) {
|
||||||
|
$stmt->execute([$roleId, $permName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
AuditLog::logUpdate('role', $roleId, [
|
||||||
|
'display_name' => $role['display_name'],
|
||||||
|
], [
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'permissions' => $permissions,
|
||||||
|
], "Upravena role '$displayName'");
|
||||||
|
|
||||||
|
successResponse(null, 'Role byla aktualizována');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete role
|
||||||
|
*/
|
||||||
|
function handleDeleteRole(PDO $pdo, int $roleId): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
$role = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$role) {
|
||||||
|
errorResponse('Role nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block deleting admin role
|
||||||
|
if ($role['name'] === 'admin') {
|
||||||
|
errorResponse('Roli administrátora nelze smazat');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if role has users
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role_id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
$userCount = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($userCount > 0) {
|
||||||
|
errorResponse("Nelze smazat roli s {$userCount} přiřazenými uživateli. Nejprve změňte roli těmto uživatelům.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete role (cascade deletes role_permissions)
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
|
||||||
|
AuditLog::logDelete('role', $roleId, $role, "Smazána role '{$role['display_name']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Role byla smazána');
|
||||||
|
}
|
||||||
21
api/admin/handlers/session-handlers.php
Normal file
21
api/admin/handlers/session-handlers.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
function get2FAInfo(PDO $pdo, int $userId): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$stmt = $pdo->prepare("SELECT totp_enabled FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
$r2fa = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||||
|
return [
|
||||||
|
'totp_enabled' => (bool) ($row['totp_enabled'] ?? false),
|
||||||
|
'require_2fa' => (bool) $r2fa->fetchColumn(),
|
||||||
|
];
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
return ['totp_enabled' => false, 'require_2fa' => false];
|
||||||
|
}
|
||||||
|
}
|
||||||
180
api/admin/handlers/sessions-handlers.php
Normal file
180
api/admin/handlers/sessions-handlers.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - List all active sessions for current user
|
||||||
|
*/
|
||||||
|
function handleGetSession(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
||||||
|
{
|
||||||
|
// Cleanup: expirované + rotované tokeny po grace period
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'DELETE FROM refresh_tokens WHERE user_id = ? AND (expires_at < NOW()'
|
||||||
|
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
||||||
|
. JWTAuth::getGracePeriod() . ' SECOND)))'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
// Jen aktivní sessions (nereplacované)
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
created_at,
|
||||||
|
expires_at,
|
||||||
|
token_hash
|
||||||
|
FROM refresh_tokens
|
||||||
|
WHERE user_id = ? AND replaced_at IS NULL
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$sessions = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Process sessions to add is_current flag and parse user agent
|
||||||
|
$processedSessions = array_map(function ($session) use ($currentTokenHash) {
|
||||||
|
return [
|
||||||
|
'id' => (int) $session['id'],
|
||||||
|
'ip_address' => $session['ip_address'],
|
||||||
|
'user_agent' => $session['user_agent'],
|
||||||
|
'device_info' => parseUserAgent($session['user_agent']),
|
||||||
|
'created_at' => $session['created_at'],
|
||||||
|
'expires_at' => $session['expires_at'],
|
||||||
|
'is_current' => $currentTokenHash && $session['token_hash'] === $currentTokenHash,
|
||||||
|
];
|
||||||
|
}, $sessions);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'sessions' => $processedSessions,
|
||||||
|
'total' => count($processedSessions),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete a specific session
|
||||||
|
*/
|
||||||
|
function handleDeleteSession(PDO $pdo, int $sessionId, int $userId, ?string $currentTokenHash): void
|
||||||
|
{
|
||||||
|
// Verify the session belongs to the current user
|
||||||
|
$stmt = $pdo->prepare('SELECT token_hash FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
||||||
|
$stmt->execute([$sessionId, $userId]);
|
||||||
|
$session = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
errorResponse('Relace nebyla nalezena', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trying to delete current session
|
||||||
|
if ($currentTokenHash && $session['token_hash'] === $currentTokenHash) {
|
||||||
|
// Check if force parameter is set
|
||||||
|
$input = getJsonInput();
|
||||||
|
if (!($input['force'] ?? false)) {
|
||||||
|
errorResponse('Nelze smazat aktuální relaci. Použijte tlačítko odhlášení.', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the session
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
||||||
|
$stmt->execute([$sessionId, $userId]);
|
||||||
|
|
||||||
|
successResponse(null, 'Relace byla úspěšně ukončena');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete all sessions except current
|
||||||
|
*/
|
||||||
|
function handleDeleteAllSessions(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
||||||
|
{
|
||||||
|
if (!$currentTokenHash) {
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$deleted = $stmt->rowCount();
|
||||||
|
} else {
|
||||||
|
// Ponechat aktuální session, smazat ostatní (včetně replaced)
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND token_hash != ?');
|
||||||
|
$stmt->execute([$userId, $currentTokenHash]);
|
||||||
|
$deleted = $stmt->rowCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'deleted' => $deleted,
|
||||||
|
], $deleted > 0 ? 'Ostatní relace byly úspěšně ukončeny' : 'Žádné další relace k ukončení');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse user agent string to extract device/browser info
|
||||||
|
*
|
||||||
|
* @return array{browser: string, os: string}
|
||||||
|
*/
|
||||||
|
function parseUserAgent(?string $userAgent): array
|
||||||
|
{
|
||||||
|
if (empty($userAgent)) {
|
||||||
|
return [
|
||||||
|
'browser' => 'Neznámý prohlížeč',
|
||||||
|
'os' => 'Neznámý systém',
|
||||||
|
'device' => 'Neznámé zařízení',
|
||||||
|
'icon' => 'device',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$browser = 'Neznámý prohlížeč';
|
||||||
|
$os = 'Neznámý systém';
|
||||||
|
$device = 'desktop';
|
||||||
|
$icon = 'desktop';
|
||||||
|
|
||||||
|
// Detect browser
|
||||||
|
if (preg_match('/Edg(e|A|iOS)?\/[\d.]+/i', $userAgent)) {
|
||||||
|
$browser = 'Microsoft Edge';
|
||||||
|
} elseif (preg_match('/OPR\/[\d.]+|Opera/i', $userAgent)) {
|
||||||
|
$browser = 'Opera';
|
||||||
|
} elseif (preg_match('/Chrome\/[\d.]+/i', $userAgent) && !preg_match('/Chromium/i', $userAgent)) {
|
||||||
|
$browser = 'Google Chrome';
|
||||||
|
} elseif (preg_match('/Firefox\/[\d.]+/i', $userAgent)) {
|
||||||
|
$browser = 'Mozilla Firefox';
|
||||||
|
} elseif (preg_match('/Safari\/[\d.]+/i', $userAgent) && !preg_match('/Chrome/i', $userAgent)) {
|
||||||
|
$browser = 'Safari';
|
||||||
|
} elseif (preg_match('/MSIE|Trident/i', $userAgent)) {
|
||||||
|
$browser = 'Internet Explorer';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect OS
|
||||||
|
if (preg_match('/Windows NT 10/i', $userAgent)) {
|
||||||
|
$os = 'Windows 10/11';
|
||||||
|
} elseif (preg_match('/Windows NT 6\.3/i', $userAgent)) {
|
||||||
|
$os = 'Windows 8.1';
|
||||||
|
} elseif (preg_match('/Windows NT 6\.2/i', $userAgent)) {
|
||||||
|
$os = 'Windows 8';
|
||||||
|
} elseif (preg_match('/Windows NT 6\.1/i', $userAgent)) {
|
||||||
|
$os = 'Windows 7';
|
||||||
|
} elseif (preg_match('/Windows/i', $userAgent)) {
|
||||||
|
$os = 'Windows';
|
||||||
|
} elseif (preg_match('/Macintosh|Mac OS X/i', $userAgent)) {
|
||||||
|
$os = 'macOS';
|
||||||
|
} elseif (preg_match('/Linux/i', $userAgent) && !preg_match('/Android/i', $userAgent)) {
|
||||||
|
$os = 'Linux';
|
||||||
|
} elseif (preg_match('/iPhone/i', $userAgent)) {
|
||||||
|
$os = 'iOS';
|
||||||
|
$device = 'mobile';
|
||||||
|
$icon = 'smartphone';
|
||||||
|
} elseif (preg_match('/iPad/i', $userAgent)) {
|
||||||
|
$os = 'iPadOS';
|
||||||
|
$device = 'tablet';
|
||||||
|
$icon = 'tablet';
|
||||||
|
} elseif (preg_match('/Android/i', $userAgent)) {
|
||||||
|
$os = 'Android';
|
||||||
|
if (preg_match('/Mobile/i', $userAgent)) {
|
||||||
|
$device = 'mobile';
|
||||||
|
$icon = 'smartphone';
|
||||||
|
} else {
|
||||||
|
$device = 'tablet';
|
||||||
|
$icon = 'tablet';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'browser' => $browser,
|
||||||
|
'os' => $os,
|
||||||
|
'device' => $device,
|
||||||
|
'icon' => $icon,
|
||||||
|
];
|
||||||
|
}
|
||||||
421
api/admin/handlers/totp-handlers.php
Normal file
421
api/admin/handlers/totp-handlers.php
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RobThree\Auth\TwoFactorAuth;
|
||||||
|
use RobThree\Auth\Providers\Qr\QRServerProvider;
|
||||||
|
|
||||||
|
function getTfa(): TwoFactorAuth
|
||||||
|
{
|
||||||
|
static $tfa = null;
|
||||||
|
if ($tfa === null) {
|
||||||
|
$tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation');
|
||||||
|
}
|
||||||
|
return $tfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET ?action=status */
|
||||||
|
function handleStatus(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'totp_enabled' => (bool) ($user['totp_enabled'] ?? false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */
|
||||||
|
function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user['totp_enabled']) {
|
||||||
|
errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = $tfa->createSecret();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
|
||||||
|
$stmt->execute([Encryption::encrypt($secret), $userId]);
|
||||||
|
|
||||||
|
$label = $user['email'] ?: $user['username'];
|
||||||
|
$qrUri = $tfa->getQRText($label, $secret);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'secret' => $secret,
|
||||||
|
'qr_uri' => $qrUri,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST ?action=enable { "code": "123456" } */
|
||||||
|
function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
$input = getJsonInput();
|
||||||
|
$code = trim($input['code'] ?? '');
|
||||||
|
|
||||||
|
if (empty($code)) {
|
||||||
|
errorResponse('Ověřovací kód je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user['totp_secret']) {
|
||||||
|
errorResponse('Nejprve vygenerujte tajný klíč (setup)');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user['totp_enabled']) {
|
||||||
|
errorResponse('2FA je již aktivní');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||||
|
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
||||||
|
errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupCodes = generateBackupCodes();
|
||||||
|
$hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?');
|
||||||
|
$stmt->execute([json_encode($hashedCodes), $userId]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA');
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'backup_codes' => $backupCodes,
|
||||||
|
], '2FA bylo úspěšně aktivováno');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST ?action=disable { "code": "123456" } */
|
||||||
|
function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
$userId = $authData['user_id'];
|
||||||
|
$input = getJsonInput();
|
||||||
|
$code = trim($input['code'] ?? '');
|
||||||
|
|
||||||
|
if (empty($code)) {
|
||||||
|
errorResponse('Ověřovací kód je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user['totp_enabled']) {
|
||||||
|
errorResponse('2FA není aktivní');
|
||||||
|
}
|
||||||
|
|
||||||
|
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||||
|
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
||||||
|
errorResponse('Neplatný ověřovací kód');
|
||||||
|
}
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'UPDATE users SET totp_enabled = 0, totp_secret = NULL,
|
||||||
|
totp_backup_codes = NULL WHERE id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA');
|
||||||
|
|
||||||
|
successResponse(null, '2FA bylo deaktivováno');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth)
|
||||||
|
* Body: { "login_token": "...", "code": "123456", "remember": false }
|
||||||
|
*/
|
||||||
|
function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->setFailClosed();
|
||||||
|
$rateLimiter->enforce('totp_2fa', 5);
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$loginToken = $input['login_token'] ?? '';
|
||||||
|
$code = trim($input['code'] ?? '');
|
||||||
|
$remember = (bool) ($input['remember'] ?? false);
|
||||||
|
|
||||||
|
if (empty($loginToken) || empty($code)) {
|
||||||
|
errorResponse('Přihlašovací token a ověřovací kód jsou povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenData = verifyLoginToken($pdo, $loginToken);
|
||||||
|
if (!$tokenData) {
|
||||||
|
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $tokenData['user_id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE u.id = ? AND u.totp_enabled = 1
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
||||||
|
if (!$tfa->verifyCode($decryptedSecret, $code, 1)) {
|
||||||
|
errorResponse('Neplatný ověřovací kód');
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLoginToken($pdo, $loginToken);
|
||||||
|
completeLogin($pdo, $user, $remember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */
|
||||||
|
function handleBackupVerify(PDO $pdo): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateLimiter = new RateLimiter();
|
||||||
|
$rateLimiter->setFailClosed();
|
||||||
|
$rateLimiter->enforce('totp_2fa', 5);
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$loginToken = $input['login_token'] ?? '';
|
||||||
|
$code = strtoupper(trim($input['code'] ?? ''));
|
||||||
|
$remember = (bool) ($input['remember'] ?? false);
|
||||||
|
|
||||||
|
if (empty($loginToken) || empty($code)) {
|
||||||
|
errorResponse('Přihlašovací token a záložní kód jsou povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenData = verifyLoginToken($pdo, $loginToken);
|
||||||
|
if (!$tokenData) {
|
||||||
|
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $tokenData['user_id'];
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE u.id = ? AND u.totp_enabled = 1
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user || !$user['totp_backup_codes']) {
|
||||||
|
errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashedCodes = json_decode($user['totp_backup_codes'], true);
|
||||||
|
$matched = false;
|
||||||
|
$remainingCodes = [];
|
||||||
|
|
||||||
|
foreach ($hashedCodes as $hashed) {
|
||||||
|
if (!$matched && password_verify($code, $hashed)) {
|
||||||
|
$matched = true;
|
||||||
|
} else {
|
||||||
|
$remainingCodes[] = $hashed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$matched) {
|
||||||
|
errorResponse('Neplatný záložní kód');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?');
|
||||||
|
$stmt->execute([json_encode($remainingCodes), $userId]);
|
||||||
|
|
||||||
|
deleteLoginToken($pdo, $loginToken);
|
||||||
|
completeLogin($pdo, $user, $remember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET ?action=get_required (admin only) */
|
||||||
|
function handleGetRequired(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
requirePermission($authData, 'settings.security');
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'require_2fa' => (bool) $stmt->fetchColumn(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST ?action=set_required { "required": true/false } (admin only) */
|
||||||
|
function handleSetRequired(PDO $pdo): void
|
||||||
|
{
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
errorResponse('Metoda není povolena', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$authData = JWTAuth::requireAuth();
|
||||||
|
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
||||||
|
requirePermission($authData, 'settings.security');
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
$required = (bool) ($input['required'] ?? false);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1");
|
||||||
|
$stmt->execute([$required ? 1 : 0]);
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'require_2fa' => $required,
|
||||||
|
], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper functions ---
|
||||||
|
|
||||||
|
/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */
|
||||||
|
function decryptTotpSecret(string $value): string
|
||||||
|
{
|
||||||
|
if (Encryption::isEncrypted($value)) {
|
||||||
|
return Encryption::decrypt($value);
|
||||||
|
}
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generovat 8 nahodnych backup kodu
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function generateBackupCodes(int $count = 8): array
|
||||||
|
{
|
||||||
|
$codes = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex
|
||||||
|
}
|
||||||
|
return $codes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Docasny login token pro 2FA (5 min) */
|
||||||
|
function createLoginToken(PDO $pdo, int $userId): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(32));
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId, $hashedToken, $expiresAt]);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overit login token
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
function verifyLoginToken(PDO $pdo, string $token): ?array
|
||||||
|
{
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT * FROM totp_login_tokens
|
||||||
|
WHERE token_hash = ? AND expires_at > NOW()
|
||||||
|
');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
return $stmt->fetch() ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Smazat login token po pouziti */
|
||||||
|
function deleteLoginToken(PDO $pdo, string $token): void
|
||||||
|
{
|
||||||
|
$hashedToken = hash('sha256', $token);
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?');
|
||||||
|
$stmt->execute([$hashedToken]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dokoncit login po uspesnem 2FA - vydat JWT + refresh token
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $user
|
||||||
|
*/
|
||||||
|
function completeLogin(PDO $pdo, array $user, bool $remember): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$user['id']]);
|
||||||
|
|
||||||
|
$userData = [
|
||||||
|
'id' => $user['id'],
|
||||||
|
'username' => $user['username'],
|
||||||
|
'email' => $user['email'],
|
||||||
|
'first_name' => $user['first_name'],
|
||||||
|
'last_name' => $user['last_name'],
|
||||||
|
'role' => $user['role_name'] ?? null,
|
||||||
|
'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null,
|
||||||
|
'is_admin' => ($user['role_name'] ?? '') === 'admin',
|
||||||
|
];
|
||||||
|
|
||||||
|
$accessToken = JWTAuth::generateAccessToken($userData);
|
||||||
|
JWTAuth::generateRefreshToken($user['id'], $remember);
|
||||||
|
|
||||||
|
AuditLog::logLogin($user['id'], $user['username']);
|
||||||
|
|
||||||
|
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
||||||
|
$require2FA = (bool) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'access_token' => $accessToken,
|
||||||
|
'expires_in' => JWTAuth::getAccessTokenExpiry(),
|
||||||
|
'user' => [
|
||||||
|
'id' => $userData['id'],
|
||||||
|
'username' => $userData['username'],
|
||||||
|
'email' => $userData['email'],
|
||||||
|
'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']),
|
||||||
|
'role' => $userData['role'],
|
||||||
|
'role_display' => $userData['role_display'],
|
||||||
|
'is_admin' => $userData['is_admin'],
|
||||||
|
'permissions' => JWTAuth::getUserPermissions($user['id']),
|
||||||
|
'totp_enabled' => true,
|
||||||
|
'require_2fa' => $require2FA,
|
||||||
|
],
|
||||||
|
], 'Přihlášení úspěšné');
|
||||||
|
}
|
||||||
660
api/admin/handlers/trips-handlers.php
Normal file
660
api/admin/handlers/trips-handlers.php
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function getLastKmForVehicle(PDO $pdo, int $vehicleId): int
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT COALESCE(
|
||||||
|
(SELECT MAX(end_km) FROM trips WHERE vehicle_id = ?),
|
||||||
|
(SELECT initial_km FROM vehicles WHERE id = ?),
|
||||||
|
0
|
||||||
|
) as last_km
|
||||||
|
');
|
||||||
|
$stmt->execute([$vehicleId, $vehicleId]);
|
||||||
|
$result = $stmt->fetch();
|
||||||
|
|
||||||
|
return $result ? (int)$result['last_km'] : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatKm(int $km): string
|
||||||
|
{
|
||||||
|
return number_format($km, 0, ',', ' ') . ' km';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GET Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Current month trips (filtered to current user)
|
||||||
|
*/
|
||||||
|
function handleGetCurrent(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$month = validateMonth();
|
||||||
|
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||||
|
FROM trips t
|
||||||
|
JOIN vehicles v ON t.vehicle_id = v.id
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.trip_date BETWEEN ? AND ?
|
||||||
|
AND t.user_id = ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate, $userId];
|
||||||
|
|
||||||
|
if ($vehicleId) {
|
||||||
|
$sql .= ' AND t.vehicle_id = ?';
|
||||||
|
$params[] = $vehicleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$trips = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get active vehicles for selection
|
||||||
|
$stmt = $pdo->query('SELECT id, spz, name, brand, model FROM vehicles WHERE is_active = 1 ORDER BY name');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$totalDistance = 0;
|
||||||
|
$businessDistance = 0;
|
||||||
|
$privateDistance = 0;
|
||||||
|
|
||||||
|
foreach ($trips as $trip) {
|
||||||
|
$totalDistance += $trip['distance'];
|
||||||
|
if ($trip['is_business']) {
|
||||||
|
$businessDistance += $trip['distance'];
|
||||||
|
} else {
|
||||||
|
$privateDistance += $trip['distance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'trips' => $trips,
|
||||||
|
'vehicles' => $vehicles,
|
||||||
|
'month' => $month,
|
||||||
|
'totals' => [
|
||||||
|
'total' => $totalDistance,
|
||||||
|
'business' => $businessDistance,
|
||||||
|
'private' => $privateDistance,
|
||||||
|
'count' => count($trips),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Trip history with filters (filtered to current user)
|
||||||
|
*/
|
||||||
|
function handleGetHistory(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$month = validateMonth();
|
||||||
|
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||||
|
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||||
|
FROM trips t
|
||||||
|
JOIN vehicles v ON t.vehicle_id = v.id
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.trip_date BETWEEN ? AND ?
|
||||||
|
AND t.user_id = ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate, $userId];
|
||||||
|
|
||||||
|
if ($vehicleId) {
|
||||||
|
$sql .= ' AND t.vehicle_id = ?';
|
||||||
|
$params[] = $vehicleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$trips = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get vehicles for filter
|
||||||
|
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles WHERE is_active = 1 ORDER BY name');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$totalDistance = 0;
|
||||||
|
$businessDistance = 0;
|
||||||
|
|
||||||
|
foreach ($trips as $trip) {
|
||||||
|
$totalDistance += $trip['distance'];
|
||||||
|
if ($trip['is_business']) {
|
||||||
|
$businessDistance += $trip['distance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'trips' => $trips,
|
||||||
|
'vehicles' => $vehicles,
|
||||||
|
'month' => $month,
|
||||||
|
'totals' => [
|
||||||
|
'total' => $totalDistance,
|
||||||
|
'business' => $businessDistance,
|
||||||
|
'count' => count($trips),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Admin view of all trips
|
||||||
|
*/
|
||||||
|
function handleGetAdmin(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$dateFrom = $_GET['date_from'] ?? null;
|
||||||
|
$dateTo = $_GET['date_to'] ?? null;
|
||||||
|
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
||||||
|
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||||
|
|
||||||
|
// Default to current month if no dates provided
|
||||||
|
if (!$dateFrom || !$dateTo) {
|
||||||
|
$month = date('Y-m');
|
||||||
|
$startDate = "{$month}-01";
|
||||||
|
$endDate = date('Y-m-t', strtotime($startDate));
|
||||||
|
} else {
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
||||||
|
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
||||||
|
}
|
||||||
|
$startDate = $dateFrom;
|
||||||
|
$endDate = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT t.*, v.spz, v.name as vehicle_name,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||||
|
FROM trips t
|
||||||
|
JOIN vehicles v ON t.vehicle_id = v.id
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.trip_date BETWEEN ? AND ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate];
|
||||||
|
|
||||||
|
if ($vehicleId) {
|
||||||
|
$sql .= ' AND t.vehicle_id = ?';
|
||||||
|
$params[] = $vehicleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filterUserId) {
|
||||||
|
$sql .= ' AND t.user_id = ?';
|
||||||
|
$params[] = $filterUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$trips = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get vehicles for filter
|
||||||
|
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get users for filter
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||||
|
);
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$totalDistance = 0;
|
||||||
|
$businessDistance = 0;
|
||||||
|
|
||||||
|
foreach ($trips as $trip) {
|
||||||
|
$totalDistance += $trip['distance'];
|
||||||
|
if ($trip['is_business']) {
|
||||||
|
$businessDistance += $trip['distance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'trips' => $trips,
|
||||||
|
'vehicles' => $vehicles,
|
||||||
|
'users' => $users,
|
||||||
|
'date_from' => $startDate,
|
||||||
|
'date_to' => $endDate,
|
||||||
|
'totals' => [
|
||||||
|
'total' => $totalDistance,
|
||||||
|
'business' => $businessDistance,
|
||||||
|
'count' => count($trips),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - All vehicles (admin)
|
||||||
|
*/
|
||||||
|
function handleGetVehicles(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT v.*, COUNT(t.id) as trip_count,
|
||||||
|
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
||||||
|
FROM vehicles v
|
||||||
|
LEFT JOIN trips t ON t.vehicle_id = v.id
|
||||||
|
GROUP BY v.id
|
||||||
|
ORDER BY v.is_active DESC, v.name
|
||||||
|
');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse(['vehicles' => $vehicles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Active vehicles for selection
|
||||||
|
*/
|
||||||
|
function handleGetActiveVehicles(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT v.id, v.spz, v.name, v.brand, v.model,
|
||||||
|
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
||||||
|
FROM vehicles v
|
||||||
|
LEFT JOIN trips t ON t.vehicle_id = v.id
|
||||||
|
WHERE v.is_active = 1
|
||||||
|
GROUP BY v.id
|
||||||
|
ORDER BY v.name
|
||||||
|
');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse(['vehicles' => $vehicles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Last km for vehicle
|
||||||
|
*/
|
||||||
|
function handleGetLastKm(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$vehicleId = (int)($_GET['vehicle_id'] ?? 0);
|
||||||
|
if (!$vehicleId) {
|
||||||
|
errorResponse('Vehicle ID je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastKm = getLastKmForVehicle($pdo, $vehicleId);
|
||||||
|
successResponse(['last_km' => $lastKm]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// POST Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Create trip
|
||||||
|
*/
|
||||||
|
function handleCreateTrip(PDO $pdo, int $userId): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$vehicleId = (int)($input['vehicle_id'] ?? 0);
|
||||||
|
$tripDate = $input['trip_date'] ?? '';
|
||||||
|
$startKm = (int)($input['start_km'] ?? 0);
|
||||||
|
$endKm = (int)($input['end_km'] ?? 0);
|
||||||
|
$routeFrom = trim($input['route_from'] ?? '');
|
||||||
|
$routeTo = trim($input['route_to'] ?? '');
|
||||||
|
$isBusiness = (int)($input['is_business'] ?? 1);
|
||||||
|
$notes = trim($input['notes'] ?? '');
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!$vehicleId) {
|
||||||
|
errorResponse('Vyberte vozidlo');
|
||||||
|
}
|
||||||
|
if (!$tripDate) {
|
||||||
|
errorResponse('Datum jízdy je povinné');
|
||||||
|
}
|
||||||
|
if (!$startKm) {
|
||||||
|
errorResponse('Počáteční stav km je povinný');
|
||||||
|
}
|
||||||
|
if (!$endKm) {
|
||||||
|
errorResponse('Konečný stav km je povinný');
|
||||||
|
}
|
||||||
|
if (!$routeFrom) {
|
||||||
|
errorResponse('Místo odjezdu je povinné');
|
||||||
|
}
|
||||||
|
if (!$routeTo) {
|
||||||
|
errorResponse('Místo příjezdu je povinné');
|
||||||
|
}
|
||||||
|
if ($endKm <= $startKm) {
|
||||||
|
errorResponse('Konečný stav km musí být větší než počáteční');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check vehicle exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM vehicles WHERE id = ? AND is_active = 1');
|
||||||
|
$stmt->execute([$vehicleId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Vozidlo neexistuje nebo není aktivní');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO trips (vehicle_id, user_id, trip_date, start_km, end_km, route_from, route_to, is_business, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([
|
||||||
|
$vehicleId, $userId, $tripDate, $startKm, $endKm,
|
||||||
|
$routeFrom, $routeTo, $isBusiness, $notes ?: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
AuditLog::logCreate('trips', $newId, $input, 'Vytvořen záznam jízdy');
|
||||||
|
|
||||||
|
successResponse(['id' => $newId], 'Jízda byla zaznamenána');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Create/update vehicle (admin)
|
||||||
|
*/
|
||||||
|
function handleVehicle(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$id = (int)($input['id'] ?? 0);
|
||||||
|
$spz = strtoupper(trim($input['spz'] ?? ''));
|
||||||
|
$name = trim($input['name'] ?? '');
|
||||||
|
$brand = trim($input['brand'] ?? '');
|
||||||
|
$model = trim($input['model'] ?? '');
|
||||||
|
$initialKm = (int)($input['initial_km'] ?? 0);
|
||||||
|
$isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1;
|
||||||
|
|
||||||
|
if (!$spz) {
|
||||||
|
errorResponse('SPZ je povinná');
|
||||||
|
}
|
||||||
|
if (!$name) {
|
||||||
|
errorResponse('Název je povinný');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($id) {
|
||||||
|
// Update
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE vehicles
|
||||||
|
SET spz = ?, name = ?, brand = ?, model = ?, initial_km = ?, is_active = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive, $id]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate('vehicles', $id, [], $input, 'Upraveno vozidlo');
|
||||||
|
successResponse(null, 'Vozidlo bylo aktualizováno');
|
||||||
|
} else {
|
||||||
|
// Create
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO vehicles (spz, name, brand, model, initial_km, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
AuditLog::logCreate('vehicles', $newId, $input, 'Vytvořeno vozidlo');
|
||||||
|
successResponse(['id' => $newId], 'Vozidlo bylo vytvořeno');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if ($e->getCode() == 23000) {
|
||||||
|
errorResponse('Vozidlo s touto SPZ již existuje');
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PUT Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - Update trip
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$trip = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$trip) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission - own trips or trips.admin
|
||||||
|
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
||||||
|
errorResponse('Nemáte oprávnění upravit tento záznam', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$vehicleId = (int)($input['vehicle_id'] ?? $trip['vehicle_id']);
|
||||||
|
$tripDate = $input['trip_date'] ?? $trip['trip_date'];
|
||||||
|
$startKm = (int)($input['start_km'] ?? $trip['start_km']);
|
||||||
|
$endKm = (int)($input['end_km'] ?? $trip['end_km']);
|
||||||
|
$routeFrom = trim($input['route_from'] ?? $trip['route_from']);
|
||||||
|
$routeTo = trim($input['route_to'] ?? $trip['route_to']);
|
||||||
|
$isBusiness = isset($input['is_business']) ? (int)$input['is_business'] : $trip['is_business'];
|
||||||
|
$notes = trim($input['notes'] ?? $trip['notes'] ?? '');
|
||||||
|
|
||||||
|
if ($endKm <= $startKm) {
|
||||||
|
errorResponse('Konečný stav km musí být větší než počáteční');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE trips
|
||||||
|
SET vehicle_id = ?, trip_date = ?, start_km = ?, end_km = ?,
|
||||||
|
route_from = ?, route_to = ?, is_business = ?, notes = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$vehicleId, $tripDate, $startKm, $endKm, $routeFrom, $routeTo, $isBusiness, $notes ?: null, $id]);
|
||||||
|
|
||||||
|
AuditLog::logUpdate('trips', $id, $trip, $input, 'Upraven záznam jízdy');
|
||||||
|
|
||||||
|
successResponse(null, 'Záznam byl aktualizován');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DELETE Handlers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete trip
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$trip = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$trip) {
|
||||||
|
errorResponse('Záznam nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission - own trips or trips.admin
|
||||||
|
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
||||||
|
errorResponse('Nemáte oprávnění smazat tento záznam', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM trips WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
AuditLog::logDelete('trips', $id, $trip, 'Smazán záznam jízdy');
|
||||||
|
|
||||||
|
successResponse(null, 'Záznam byl smazán');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete vehicle (admin)
|
||||||
|
*/
|
||||||
|
function handleDeleteVehicle(PDO $pdo, int $id): void
|
||||||
|
{
|
||||||
|
if (!$id) {
|
||||||
|
errorResponse('ID je povinné');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$vehicle = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$vehicle) {
|
||||||
|
errorResponse('Vozidlo nebylo nalezeno', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vehicle has trips
|
||||||
|
$stmt = $pdo->prepare('SELECT COUNT(*) FROM trips WHERE vehicle_id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$tripCount = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
if ($tripCount > 0) {
|
||||||
|
errorResponse(
|
||||||
|
"Nelze smazat vozidlo s {$tripCount} záznamy jízd. Nejprve smažte záznamy jízd nebo deaktivujte vozidlo."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM vehicles WHERE id = ?');
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
|
||||||
|
AuditLog::logDelete('vehicles', $id, $vehicle, 'Smazáno vozidlo');
|
||||||
|
|
||||||
|
successResponse(null, 'Vozidlo bylo smazáno');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Print Handler
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date range for display
|
||||||
|
*/
|
||||||
|
function formatPeriodName(string $startDate, string $endDate): string
|
||||||
|
{
|
||||||
|
$start = new DateTime($startDate);
|
||||||
|
$end = new DateTime($endDate);
|
||||||
|
|
||||||
|
// If same month
|
||||||
|
if ($start->format('Y-m') === $end->format('Y-m')) {
|
||||||
|
return getCzechMonthName((int)$start->format('n')) . ' ' . $start->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If same year
|
||||||
|
if ($start->format('Y') === $end->format('Y')) {
|
||||||
|
return $start->format('j.n.') . ' - ' . $end->format('j.n.Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different years
|
||||||
|
return $start->format('j.n.Y') . ' - ' . $end->format('j.n.Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - Print data for trips (admin)
|
||||||
|
*/
|
||||||
|
function handleGetPrint(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$dateFrom = $_GET['date_from'] ?? null;
|
||||||
|
$dateTo = $_GET['date_to'] ?? null;
|
||||||
|
$vehicleId = isset($_GET['vehicle_id']) && $_GET['vehicle_id'] !== '' ? (int)$_GET['vehicle_id'] : null;
|
||||||
|
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
||||||
|
|
||||||
|
// Default to current month if no dates provided
|
||||||
|
if (!$dateFrom || !$dateTo) {
|
||||||
|
$startDate = date('Y-m-01');
|
||||||
|
$endDate = date('Y-m-t');
|
||||||
|
} else {
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
||||||
|
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
||||||
|
}
|
||||||
|
$startDate = $dateFrom;
|
||||||
|
$endDate = $dateTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = "
|
||||||
|
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
||||||
|
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
||||||
|
FROM trips t
|
||||||
|
JOIN vehicles v ON t.vehicle_id = v.id
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.trip_date BETWEEN ? AND ?
|
||||||
|
";
|
||||||
|
$params = [$startDate, $endDate];
|
||||||
|
|
||||||
|
if ($vehicleId) {
|
||||||
|
$sql .= ' AND t.vehicle_id = ?';
|
||||||
|
$params[] = $vehicleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filterUserId) {
|
||||||
|
$sql .= ' AND t.user_id = ?';
|
||||||
|
$params[] = $filterUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql .= ' ORDER BY t.trip_date ASC, t.start_km ASC';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare($sql);
|
||||||
|
$stmt->execute($params);
|
||||||
|
$trips = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get vehicles for filter
|
||||||
|
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
||||||
|
$vehicles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get users for filter
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||||
|
);
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
$totalDistance = 0;
|
||||||
|
$businessDistance = 0;
|
||||||
|
$privateDistance = 0;
|
||||||
|
|
||||||
|
foreach ($trips as $trip) {
|
||||||
|
$totalDistance += $trip['distance'];
|
||||||
|
if ($trip['is_business']) {
|
||||||
|
$businessDistance += $trip['distance'];
|
||||||
|
} else {
|
||||||
|
$privateDistance += $trip['distance'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get selected vehicle/user names for header
|
||||||
|
$selectedVehicleName = '';
|
||||||
|
if ($vehicleId) {
|
||||||
|
$stmt = $pdo->prepare("SELECT CONCAT(spz, ' - ', name) as name FROM vehicles WHERE id = ?");
|
||||||
|
$stmt->execute([$vehicleId]);
|
||||||
|
$v = $stmt->fetch();
|
||||||
|
$selectedVehicleName = $v ? $v['name'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedUserName = '';
|
||||||
|
if ($filterUserId) {
|
||||||
|
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$filterUserId]);
|
||||||
|
$u = $stmt->fetch();
|
||||||
|
$selectedUserName = $u ? $u['name'] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'trips' => $trips,
|
||||||
|
'vehicles' => $vehicles,
|
||||||
|
'users' => $users,
|
||||||
|
'date_from' => $startDate,
|
||||||
|
'date_to' => $endDate,
|
||||||
|
'period_name' => formatPeriodName($startDate, $endDate),
|
||||||
|
'selected_vehicle' => $vehicleId,
|
||||||
|
'selected_vehicle_name' => $selectedVehicleName,
|
||||||
|
'selected_user' => $filterUserId,
|
||||||
|
'selected_user_name' => $selectedUserName,
|
||||||
|
'totals' => [
|
||||||
|
'total' => $totalDistance,
|
||||||
|
'business' => $businessDistance,
|
||||||
|
'private' => $privateDistance,
|
||||||
|
'count' => count($trips),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
273
api/admin/handlers/users-handlers.php
Normal file
273
api/admin/handlers/users-handlers.php
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET - List all users
|
||||||
|
*/
|
||||||
|
function handleGetUser(PDO $pdo): void
|
||||||
|
{
|
||||||
|
$stmt = $pdo->query('
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.email,
|
||||||
|
u.first_name,
|
||||||
|
u.last_name,
|
||||||
|
u.role_id,
|
||||||
|
u.is_active,
|
||||||
|
u.last_login,
|
||||||
|
u.created_at,
|
||||||
|
r.name as role_name,
|
||||||
|
r.display_name as role_display_name
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN roles r ON u.role_id = r.id
|
||||||
|
ORDER BY u.created_at DESC
|
||||||
|
');
|
||||||
|
$users = $stmt->fetchAll();
|
||||||
|
|
||||||
|
// Get roles for dropdown
|
||||||
|
$stmt = $pdo->query('SELECT id, name, display_name FROM roles ORDER BY id');
|
||||||
|
$roles = $stmt->fetchAll();
|
||||||
|
|
||||||
|
successResponse([
|
||||||
|
'users' => $users,
|
||||||
|
'roles' => $roles,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST - Create new user
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleCreateUser(PDO $pdo, array $authData): void
|
||||||
|
{
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$requiredFields = [
|
||||||
|
'username' => 'Uživatelské jméno',
|
||||||
|
'email' => 'E-mail',
|
||||||
|
'password' => 'Heslo',
|
||||||
|
'first_name' => 'Jméno',
|
||||||
|
'last_name' => 'Příjmení',
|
||||||
|
'role_id' => 'Role',
|
||||||
|
];
|
||||||
|
foreach ($requiredFields as $field => $label) {
|
||||||
|
if (empty($input[$field])) {
|
||||||
|
errorResponse("$label je povinné");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = sanitize($input['username']);
|
||||||
|
$email = sanitize($input['email']);
|
||||||
|
$password = $input['password'];
|
||||||
|
$firstName = sanitize($input['first_name']);
|
||||||
|
$lastName = sanitize($input['last_name']);
|
||||||
|
$roleId = (int) $input['role_id'];
|
||||||
|
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : 1;
|
||||||
|
|
||||||
|
// Non-admin nesmí přiřadit admin roli
|
||||||
|
if (!($authData['user']['is_admin'] ?? false)) {
|
||||||
|
$stmt = $pdo->prepare('SELECT name FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
$targetRole = $stmt->fetch();
|
||||||
|
if ($targetRole && $targetRole['name'] === 'admin') {
|
||||||
|
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!isValidEmail($email)) {
|
||||||
|
errorResponse('Neplatný formát e-mailu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate password length
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
errorResponse('Heslo musí mít alespoň 8 znaků');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username uniqueness
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
|
||||||
|
$stmt->execute([$username]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('Uživatelské jméno již existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
||||||
|
$stmt->execute([$email]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('E-mail již existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
if (!$stmt->fetch()) {
|
||||||
|
errorResponse('Neplatná role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
$passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
||||||
|
|
||||||
|
// Insert user
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
INSERT INTO users (username, email, password_hash, first_name, last_name, role_id, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive]);
|
||||||
|
|
||||||
|
$newUserId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
AuditLog::logCreate('user', $newUserId, [
|
||||||
|
'username' => $username,
|
||||||
|
'email' => $email,
|
||||||
|
'first_name' => $firstName,
|
||||||
|
'last_name' => $lastName,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'is_active' => $isActive,
|
||||||
|
], "Vytvořen uživatel '$username'");
|
||||||
|
|
||||||
|
successResponse(['id' => $newUserId], 'Uživatel byl úspěšně vytvořen');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT - Update user
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void
|
||||||
|
{
|
||||||
|
// Get existing user
|
||||||
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$existingUser = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$existingUser) {
|
||||||
|
errorResponse('Uživatel nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input = getJsonInput();
|
||||||
|
|
||||||
|
$username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username'];
|
||||||
|
$email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email'];
|
||||||
|
$firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name'];
|
||||||
|
$lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name'];
|
||||||
|
$roleId = isset($input['role_id']) ? (int) $input['role_id'] : $existingUser['role_id'];
|
||||||
|
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : $existingUser['is_active'];
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
if (!isValidEmail($email)) {
|
||||||
|
errorResponse('Neplatný formát e-mailu');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username uniqueness (excluding current user)
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?');
|
||||||
|
$stmt->execute([$username, $userId]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('Uživatelské jméno již existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness (excluding current user)
|
||||||
|
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?');
|
||||||
|
$stmt->execute([$email, $userId]);
|
||||||
|
if ($stmt->fetch()) {
|
||||||
|
errorResponse('E-mail již existuje');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role exists
|
||||||
|
$stmt = $pdo->prepare('SELECT id, name FROM roles WHERE id = ?');
|
||||||
|
$stmt->execute([$roleId]);
|
||||||
|
$targetRole = $stmt->fetch();
|
||||||
|
if (!$targetRole) {
|
||||||
|
errorResponse('Neplatná role');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admin nesmí přiřadit admin roli
|
||||||
|
if (!($authData['user']['is_admin'] ?? false) && $targetRole['name'] === 'admin') {
|
||||||
|
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
if (!empty($input['password'])) {
|
||||||
|
// Validate password length
|
||||||
|
if (strlen($input['password']) < 8) {
|
||||||
|
errorResponse('Heslo musí mít alespoň 8 znaků');
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, password_hash = ?,
|
||||||
|
first_name = ?, last_name = ?, role_id = ?,
|
||||||
|
is_active = ?, password_changed_at = NOW()
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive, $userId]);
|
||||||
|
} else {
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, first_name = ?, last_name = ?, role_id = ?, is_active = ?
|
||||||
|
WHERE id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$username, $email, $firstName, $lastName, $roleId, $isActive, $userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: With JWT, user data is in the token - no session to update
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
AuditLog::logUpdate('user', $userId, [
|
||||||
|
'username' => $existingUser['username'],
|
||||||
|
'email' => $existingUser['email'],
|
||||||
|
'first_name' => $existingUser['first_name'],
|
||||||
|
'last_name' => $existingUser['last_name'],
|
||||||
|
'role_id' => $existingUser['role_id'],
|
||||||
|
'is_active' => $existingUser['is_active'],
|
||||||
|
], [
|
||||||
|
'username' => $username,
|
||||||
|
'email' => $email,
|
||||||
|
'first_name' => $firstName,
|
||||||
|
'last_name' => $lastName,
|
||||||
|
'role_id' => $roleId,
|
||||||
|
'is_active' => $isActive,
|
||||||
|
], "Upraven uživatel '$username'");
|
||||||
|
|
||||||
|
successResponse(null, 'Uživatel byl úspěšně aktualizován');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE - Delete user
|
||||||
|
*/
|
||||||
|
function handleDeleteUser(PDO $pdo, int $userId, int $currentUserId): void
|
||||||
|
{
|
||||||
|
// Prevent self-deletion
|
||||||
|
if ($userId === $currentUserId) {
|
||||||
|
errorResponse('Nemůžete smazat svůj vlastní účet');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user for audit log
|
||||||
|
$stmt = $pdo->prepare('SELECT username FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
errorResponse('Uživatel nebyl nalezen', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete related records first (refresh tokens for JWT auth)
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
|
||||||
|
// Audit log
|
||||||
|
AuditLog::logDelete('user', $userId, ['username' => $user['username']], "Smazán uživatel '{$user['username']}'");
|
||||||
|
|
||||||
|
successResponse(null, 'Uživatel byl úspěšně smazán');
|
||||||
|
}
|
||||||
@@ -12,8 +12,6 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
|
||||||
// QR kod
|
|
||||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|
||||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
use chillerlan\QRCode\QRCode;
|
use chillerlan\QRCode\QRCode;
|
||||||
use chillerlan\QRCode\QROptions;
|
use chillerlan\QRCode\QROptions;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ require_once dirname(__DIR__) . '/config.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
|
require_once __DIR__ . '/handlers/invoices-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -101,703 +102,3 @@ try {
|
|||||||
// --- Status transitions ---
|
// --- Status transitions ---
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
function getValidTransitions(string $status): array
|
|
||||||
{
|
|
||||||
return match ($status) {
|
|
||||||
'issued' => ['paid'],
|
|
||||||
'overdue' => ['paid'],
|
|
||||||
default => []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Invoice number generation ---
|
|
||||||
|
|
||||||
function generateInvoiceNumber(PDO $pdo): string
|
|
||||||
{
|
|
||||||
$yy = date('y');
|
|
||||||
|
|
||||||
$settings = $pdo->query('SELECT invoice_type_code FROM company_settings LIMIT 1')->fetch();
|
|
||||||
$typeCode = ($settings && !empty($settings['invoice_type_code'])) ? $settings['invoice_type_code'] : '81';
|
|
||||||
|
|
||||||
$prefix = $yy . $typeCode;
|
|
||||||
$prefixLen = strlen($prefix);
|
|
||||||
$likePattern = $prefix . '%';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ? + 1) AS UNSIGNED)), 0)
|
|
||||||
FROM invoices WHERE invoice_number LIKE ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$prefixLen, $likePattern]);
|
|
||||||
$max = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
return sprintf('%s%04d', $prefix, $max + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stats ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spocita celkovou castku faktur seskupenou podle meny + CZK prepocet dle kurzu k datu faktury.
|
|
||||||
*
|
|
||||||
* @param array<int, string|int|float> $params
|
|
||||||
* @return array{amounts: array<int, array{amount: float, currency: string}>, count: int, total_czk: float}
|
|
||||||
*/
|
|
||||||
function sumInvoicesByCurrency(PDO $pdo, string $where, array $params): array
|
|
||||||
{
|
|
||||||
// Per-faktura pro presny prepocet kurzem k datu
|
|
||||||
$perInvoiceSql = "
|
|
||||||
SELECT i.id, i.currency, i.issue_date,
|
|
||||||
COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
|
||||||
+ COALESCE(SUM(CASE WHEN i.apply_vat
|
|
||||||
THEN ii.quantity * ii.unit_price * ii.vat_rate / 100
|
|
||||||
ELSE 0 END), 0) AS total
|
|
||||||
FROM invoices i
|
|
||||||
JOIN invoice_items ii ON ii.invoice_id = i.id
|
|
||||||
$where
|
|
||||||
GROUP BY i.id, i.currency, i.issue_date
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($perInvoiceSql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$rows = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Seskupit podle meny pro zobrazeni
|
|
||||||
$byCurrency = [];
|
|
||||||
$czkItems = [];
|
|
||||||
foreach ($rows as $r) {
|
|
||||||
$cur = $r['currency'];
|
|
||||||
$amt = round((float) $r['total'], 2);
|
|
||||||
$byCurrency[$cur] = ($byCurrency[$cur] ?? 0) + $amt;
|
|
||||||
$czkItems[] = [
|
|
||||||
'amount' => $amt,
|
|
||||||
'currency' => $cur,
|
|
||||||
'date' => $r['issue_date'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$amounts = [];
|
|
||||||
foreach ($byCurrency as $cur => $total) {
|
|
||||||
$amounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnb = CnbRates::getInstance();
|
|
||||||
$totalCzk = $cnb->sumToCzk($czkItems);
|
|
||||||
|
|
||||||
$countSql = "SELECT COUNT(*) FROM invoices i $where";
|
|
||||||
$countStmt = $pdo->prepare($countSql);
|
|
||||||
$countStmt->execute($params);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'amounts' => $amounts,
|
|
||||||
'count' => (int) $countStmt->fetchColumn(),
|
|
||||||
'total_czk' => $totalCzk,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetStats(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
|
||||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
|
||||||
|
|
||||||
// Lazy overdue detekce
|
|
||||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
|
||||||
|
|
||||||
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
|
||||||
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
|
||||||
|
|
||||||
// a) Uhrazeno v danem mesici (dle data vystaveni, ne uhrazeni)
|
|
||||||
$paidWhere = "WHERE i.status = 'paid' AND i.issue_date BETWEEN ? AND ?";
|
|
||||||
$paid = sumInvoicesByCurrency($pdo, $paidWhere, [$monthStart, $monthEnd]);
|
|
||||||
|
|
||||||
// b) Ceka uhrada (aktualni stav)
|
|
||||||
$awaiting = sumInvoicesByCurrency($pdo, "WHERE i.status = 'issued'", []);
|
|
||||||
|
|
||||||
// c) Po splatnosti (aktualni stav)
|
|
||||||
$overdue = sumInvoicesByCurrency($pdo, "WHERE i.status = 'overdue'", []);
|
|
||||||
|
|
||||||
// d) DPH v danem mesici - per faktura pro kurz k datu
|
|
||||||
$vatSql = "
|
|
||||||
SELECT i.id, i.currency, i.issue_date,
|
|
||||||
COALESCE(SUM(ii.quantity * ii.unit_price * ii.vat_rate / 100), 0) AS vat_total
|
|
||||||
FROM invoices i
|
|
||||||
JOIN invoice_items ii ON ii.invoice_id = i.id
|
|
||||||
WHERE i.apply_vat = 1 AND i.issue_date BETWEEN ? AND ?
|
|
||||||
GROUP BY i.id, i.currency, i.issue_date
|
|
||||||
";
|
|
||||||
$vatStmt = $pdo->prepare($vatSql);
|
|
||||||
$vatStmt->execute([$monthStart, $monthEnd]);
|
|
||||||
$vatRows = $vatStmt->fetchAll();
|
|
||||||
|
|
||||||
$vatByCurrency = [];
|
|
||||||
$vatCzkItems = [];
|
|
||||||
foreach ($vatRows as $r) {
|
|
||||||
$cur = $r['currency'];
|
|
||||||
$amt = round((float) $r['vat_total'], 2);
|
|
||||||
$vatByCurrency[$cur] = ($vatByCurrency[$cur] ?? 0) + $amt;
|
|
||||||
$vatCzkItems[] = [
|
|
||||||
'amount' => $amt,
|
|
||||||
'currency' => $cur,
|
|
||||||
'date' => $r['issue_date'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$vatAmounts = [];
|
|
||||||
foreach ($vatByCurrency as $cur => $total) {
|
|
||||||
$vatAmounts[] = ['amount' => round($total, 2), 'currency' => $cur];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnb = CnbRates::getInstance();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'paid_month' => $paid['amounts'],
|
|
||||||
'paid_month_czk' => $paid['total_czk'],
|
|
||||||
'paid_month_count' => $paid['count'],
|
|
||||||
'awaiting' => $awaiting['amounts'],
|
|
||||||
'awaiting_czk' => $awaiting['total_czk'],
|
|
||||||
'awaiting_count' => $awaiting['count'],
|
|
||||||
'overdue' => $overdue['amounts'],
|
|
||||||
'overdue_czk' => $overdue['total_czk'],
|
|
||||||
'overdue_count' => $overdue['count'],
|
|
||||||
'vat_month' => $vatAmounts,
|
|
||||||
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
|
||||||
'month' => $month,
|
|
||||||
'year' => $year,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handlers ---
|
|
||||||
|
|
||||||
function handleGetList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$search = trim($_GET['search'] ?? '');
|
|
||||||
$statusFilter = trim($_GET['status'] ?? '');
|
|
||||||
$sort = $_GET['sort'] ?? 'created_at';
|
|
||||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
||||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
||||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
|
||||||
|
|
||||||
$sortMap = [
|
|
||||||
'InvoiceNumber' => 'i.invoice_number',
|
|
||||||
'invoice_number' => 'i.invoice_number',
|
|
||||||
'CreatedAt' => 'i.created_at',
|
|
||||||
'created_at' => 'i.created_at',
|
|
||||||
'Status' => 'i.status',
|
|
||||||
'status' => 'i.status',
|
|
||||||
'DueDate' => 'i.due_date',
|
|
||||||
'due_date' => 'i.due_date',
|
|
||||||
'IssueDate' => 'i.issue_date',
|
|
||||||
'issue_date' => 'i.issue_date',
|
|
||||||
];
|
|
||||||
if (!isset($sortMap[$sort])) {
|
|
||||||
errorResponse('Neplatný parametr řazení', 400);
|
|
||||||
}
|
|
||||||
$sortCol = $sortMap[$sort];
|
|
||||||
|
|
||||||
// Lazy overdue detekce
|
|
||||||
$pdo->exec("UPDATE invoices SET status = 'overdue' WHERE status = 'issued' AND due_date < CURDATE()");
|
|
||||||
|
|
||||||
$where = 'WHERE 1=1';
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($search) {
|
|
||||||
$search = mb_substr($search, 0, 100);
|
|
||||||
$where .= ' AND (i.invoice_number LIKE ? OR c.name LIKE ? OR c.company_id LIKE ?)';
|
|
||||||
$searchParam = "%{$search}%";
|
|
||||||
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statusFilter) {
|
|
||||||
$statuses = array_filter(explode(',', $statusFilter));
|
|
||||||
if ($statuses) {
|
|
||||||
$placeholders = implode(',', array_fill(0, count($statuses), '?'));
|
|
||||||
$where .= " AND i.status IN ($placeholders)";
|
|
||||||
$params = array_merge($params, $statuses);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$countSql = "
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
|
||||||
$where
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($countSql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$total = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT i.id, i.invoice_number, i.order_id, i.status, i.currency,
|
|
||||||
i.issue_date, i.due_date, i.paid_date, i.created_at, i.apply_vat,
|
|
||||||
c.name as customer_name,
|
|
||||||
(SELECT COALESCE(SUM(ii.quantity * ii.unit_price), 0)
|
|
||||||
FROM invoice_items ii WHERE ii.invoice_id = i.id) as subtotal,
|
|
||||||
o.order_number
|
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
|
||||||
LEFT JOIN orders o ON i.order_id = o.id
|
|
||||||
$where
|
|
||||||
ORDER BY $sortCol $order
|
|
||||||
LIMIT $perPage OFFSET $offset
|
|
||||||
";
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$invoices = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Dopocitat celkovou castku s DPH
|
|
||||||
foreach ($invoices as &$inv) {
|
|
||||||
$subtotal = (float) $inv['subtotal'];
|
|
||||||
if ($inv['apply_vat']) {
|
|
||||||
$vatStmt = $pdo->prepare('
|
|
||||||
SELECT COALESCE(SUM(quantity * unit_price * vat_rate / 100), 0)
|
|
||||||
FROM invoice_items WHERE invoice_id = ?
|
|
||||||
');
|
|
||||||
$vatStmt->execute([$inv['id']]);
|
|
||||||
$vatAmount = (float) $vatStmt->fetchColumn();
|
|
||||||
$inv['total'] = $subtotal + $vatAmount;
|
|
||||||
} else {
|
|
||||||
$inv['total'] = $subtotal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unset($inv);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'invoices' => $invoices,
|
|
||||||
'total' => $total,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
// Lazy overdue
|
|
||||||
$pdo->prepare(
|
|
||||||
"UPDATE invoices SET status = 'overdue' WHERE id = ? AND status = 'issued' AND due_date < CURDATE()"
|
|
||||||
)->execute([$id]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT i.*, c.name as customer_name, o.order_number
|
|
||||||
FROM invoices i
|
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
|
||||||
LEFT JOIN orders o ON i.order_id = o.id
|
|
||||||
WHERE i.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polozky
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice['items'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Zakaznik
|
|
||||||
if ($invoice['customer_id']) {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
|
||||||
FROM customers WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$invoice['customer_id']]);
|
|
||||||
$invoice['customer'] = $stmt->fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
$invoice['valid_transitions'] = getValidTransitions($invoice['status']);
|
|
||||||
|
|
||||||
successResponse($invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetNextNumber(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$number = generateInvoiceNumber($pdo);
|
|
||||||
successResponse(['number' => $number]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetOrderData(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT o.id, o.order_number, o.customer_id, o.status, o.currency,
|
|
||||||
o.language, o.vat_rate, o.apply_vat, o.exchange_rate,
|
|
||||||
o.created_at, o.modified_at,
|
|
||||||
c.name as customer_name
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN customers c ON o.customer_id = c.id
|
|
||||||
WHERE o.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$order) {
|
|
||||||
errorResponse('Objednávka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Polozky objednavky
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order['items'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse($order);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $authData */
|
|
||||||
function handleCreateInvoice(PDO $pdo, array $authData): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$customerId = isset($input['customer_id']) ? (int) $input['customer_id'] : null;
|
|
||||||
$orderId = !empty($input['order_id']) ? (int) $input['order_id'] : null;
|
|
||||||
$issueDate = trim($input['issue_date'] ?? '');
|
|
||||||
$dueDate = trim($input['due_date'] ?? '');
|
|
||||||
$taxDate = trim($input['tax_date'] ?? '');
|
|
||||||
$currency = trim($input['currency'] ?? 'CZK');
|
|
||||||
$applyVat = isset($input['apply_vat']) ? (int) $input['apply_vat'] : 1;
|
|
||||||
$paymentMethod = trim($input['payment_method'] ?? 'Příkazem');
|
|
||||||
$constantSymbol = trim($input['constant_symbol'] ?? '0308');
|
|
||||||
$issuedBy = trim($input['issued_by'] ?? '');
|
|
||||||
$notes = trim($input['notes'] ?? '');
|
|
||||||
$items = $input['items'] ?? [];
|
|
||||||
|
|
||||||
// Bankovni udaje
|
|
||||||
$bankName = trim($input['bank_name'] ?? '');
|
|
||||||
$bankSwift = trim($input['bank_swift'] ?? '');
|
|
||||||
$bankIban = trim($input['bank_iban'] ?? '');
|
|
||||||
$bankAccount = trim($input['bank_account'] ?? '');
|
|
||||||
|
|
||||||
if (!$customerId) {
|
|
||||||
errorResponse('Zákazník je povinný');
|
|
||||||
}
|
|
||||||
if (!$issueDate || !$dueDate || !$taxDate) {
|
|
||||||
errorResponse('Všechna data (vystavení, splatnost, DÚZP) jsou povinná');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace formatu dat
|
|
||||||
foreach (['issue_date' => $issueDate, 'due_date' => $dueDate, 'tax_date' => $taxDate] as $label => $date) {
|
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) {
|
|
||||||
errorResponse("Neplatný formát data: $label");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace meny
|
|
||||||
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
|
||||||
if (!in_array($currency, $validCurrencies)) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delkove limity
|
|
||||||
if (mb_strlen($paymentMethod) > 50) {
|
|
||||||
errorResponse('Forma úhrady je příliš dlouhá (max 50 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($issuedBy) > 255) {
|
|
||||||
errorResponse('Vystavil je příliš dlouhé (max 255 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($notes) > 5000) {
|
|
||||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bankName) > 255) {
|
|
||||||
errorResponse('Název banky je příliš dlouhý');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bankIban) > 50) {
|
|
||||||
errorResponse('IBAN je příliš dlouhý');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bankSwift) > 20) {
|
|
||||||
errorResponse('BIC/SWIFT je příliš dlouhý');
|
|
||||||
}
|
|
||||||
if (mb_strlen($bankAccount) > 50) {
|
|
||||||
errorResponse('Číslo účtu je příliš dlouhé');
|
|
||||||
}
|
|
||||||
if (!$bankAccount && !$bankIban) {
|
|
||||||
errorResponse('Bankovní účet je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($items)) {
|
|
||||||
errorResponse('Faktura musí mít alespoň jednu položku');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace polozek
|
|
||||||
foreach ($items as $i => $item) {
|
|
||||||
$qty = $item['quantity'] ?? 1;
|
|
||||||
$price = $item['unit_price'] ?? 0;
|
|
||||||
$vatRate = $item['vat_rate'] ?? 21;
|
|
||||||
if (!is_numeric($qty) || $qty < 0) {
|
|
||||||
errorResponse('Položka #' . ($i + 1) . ': neplatné množství');
|
|
||||||
}
|
|
||||||
if (!is_numeric($price)) {
|
|
||||||
errorResponse('Položka #' . ($i + 1) . ': neplatná cena');
|
|
||||||
}
|
|
||||||
if (!is_numeric($vatRate) || $vatRate < 0 || $vatRate > 100) {
|
|
||||||
errorResponse('Položka #' . ($i + 1) . ': neplatná sazba DPH');
|
|
||||||
}
|
|
||||||
if (mb_strlen($item['description'] ?? '') > 500) {
|
|
||||||
errorResponse('Položka #' . ($i + 1) . ': popis je příliš dlouhý (max 500 znaků)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overit zakaznika
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$customerId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock pro cislovani
|
|
||||||
$locked = $pdo->query("SELECT GET_LOCK('boha_invoice_number', 5)")->fetchColumn();
|
|
||||||
if (!$locked) {
|
|
||||||
errorResponse('Nepodařilo se získat zámek pro číslo faktury, zkuste to znovu', 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$invoiceNumber = !empty($input['invoice_number'])
|
|
||||||
? trim($input['invoice_number'])
|
|
||||||
: generateInvoiceNumber($pdo);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
INSERT INTO invoices (
|
|
||||||
invoice_number, order_id, customer_id, status, currency,
|
|
||||||
vat_rate, apply_vat, payment_method, constant_symbol,
|
|
||||||
bank_name, bank_swift, bank_iban, bank_account,
|
|
||||||
issue_date, due_date, tax_date, issued_by, notes,
|
|
||||||
created_at, modified_at
|
|
||||||
) VALUES (?, ?, ?, 'issued', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
$invoiceNumber,
|
|
||||||
$orderId,
|
|
||||||
$customerId,
|
|
||||||
$currency,
|
|
||||||
$input['vat_rate'] ?? 21,
|
|
||||||
$applyVat,
|
|
||||||
$paymentMethod,
|
|
||||||
$constantSymbol,
|
|
||||||
$bankName,
|
|
||||||
$bankSwift,
|
|
||||||
$bankIban,
|
|
||||||
$bankAccount,
|
|
||||||
$issueDate,
|
|
||||||
$dueDate,
|
|
||||||
$taxDate,
|
|
||||||
$issuedBy,
|
|
||||||
$notes,
|
|
||||||
]);
|
|
||||||
$invoiceId = (int) $pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Vlozit polozky
|
|
||||||
$itemStmt = $pdo->prepare('
|
|
||||||
INSERT INTO invoice_items (
|
|
||||||
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
foreach ($items as $i => $item) {
|
|
||||||
$itemStmt->execute([
|
|
||||||
$invoiceId,
|
|
||||||
trim($item['description'] ?? ''),
|
|
||||||
$item['quantity'] ?? 1,
|
|
||||||
trim($item['unit'] ?? ''),
|
|
||||||
$item['unit_price'] ?? 0,
|
|
||||||
$item['vat_rate'] ?? 21,
|
|
||||||
$item['position'] ?? $i,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
|
||||||
|
|
||||||
AuditLog::logCreate('invoices_invoice', $invoiceId, [
|
|
||||||
'invoice_number' => $invoiceNumber,
|
|
||||||
'customer_id' => $customerId,
|
|
||||||
'order_id' => $orderId,
|
|
||||||
], "Vytvořena faktura '$invoiceNumber'");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'invoice_id' => $invoiceId,
|
|
||||||
'invoice_number' => $invoiceNumber,
|
|
||||||
], 'Faktura byla vystavena');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_invoice_number')");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateInvoice(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$newStatus = $input['status'] ?? null;
|
|
||||||
$isDraft = $invoice['status'] === 'issued';
|
|
||||||
|
|
||||||
// Zmena stavu
|
|
||||||
if ($newStatus && $newStatus !== $invoice['status']) {
|
|
||||||
$valid = getValidTransitions($invoice['status']);
|
|
||||||
if (!in_array($newStatus, $valid)) {
|
|
||||||
errorResponse("Neplatný přechod stavu z '{$invoice['status']}' na '$newStatus'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$updates = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($newStatus !== null && $newStatus !== $invoice['status']) {
|
|
||||||
$updates[] = 'status = ?';
|
|
||||||
$params[] = $newStatus;
|
|
||||||
|
|
||||||
if ($newStatus === 'paid') {
|
|
||||||
$updates[] = 'paid_date = CURDATE()';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// V issued stavu lze editovat vsechna pole
|
|
||||||
if ($isDraft) {
|
|
||||||
// Validace dat
|
|
||||||
foreach (['issue_date', 'due_date', 'tax_date'] as $dateField) {
|
|
||||||
if (
|
|
||||||
isset($input[$dateField])
|
|
||||||
&& (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $input[$dateField]) || !strtotime($input[$dateField]))
|
|
||||||
) {
|
|
||||||
errorResponse("Neplatný formát data: $dateField");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validace meny
|
|
||||||
if (isset($input['currency']) && !in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
// Validace DPH
|
|
||||||
if (
|
|
||||||
isset($input['vat_rate'])
|
|
||||||
&& (!is_numeric($input['vat_rate']) || $input['vat_rate'] < 0 || $input['vat_rate'] > 100)
|
|
||||||
) {
|
|
||||||
errorResponse('Neplatná sazba DPH');
|
|
||||||
}
|
|
||||||
// Validace zakaznika
|
|
||||||
if (isset($input['customer_id'])) {
|
|
||||||
$custStmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
|
||||||
$custStmt->execute([(int)$input['customer_id']]);
|
|
||||||
if (!$custStmt->fetch()) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stringFields = [
|
|
||||||
'issue_date' => 20, 'due_date' => 20, 'tax_date' => 20,
|
|
||||||
'payment_method' => 50, 'constant_symbol' => 10,
|
|
||||||
'bank_name' => 255, 'bank_swift' => 20, 'bank_iban' => 50, 'bank_account' => 50,
|
|
||||||
'issued_by' => 255,
|
|
||||||
];
|
|
||||||
foreach ($stringFields as $field => $maxLen) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$val = trim((string)$input[$field]);
|
|
||||||
if (mb_strlen($val) > $maxLen) {
|
|
||||||
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
|
||||||
}
|
|
||||||
$updates[] = "$field = ?";
|
|
||||||
$params[] = $val;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$numericFields = ['currency', 'vat_rate', 'apply_vat', 'customer_id'];
|
|
||||||
foreach ($numericFields as $field) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$updates[] = "$field = ?";
|
|
||||||
$params[] = $input[$field];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aktualizace polozek
|
|
||||||
if (isset($input['items']) && is_array($input['items'])) {
|
|
||||||
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
|
||||||
|
|
||||||
$itemStmt = $pdo->prepare('
|
|
||||||
INSERT INTO invoice_items (
|
|
||||||
invoice_id, description, quantity, unit, unit_price, vat_rate, position
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
foreach ($input['items'] as $i => $item) {
|
|
||||||
$itemStmt->execute([
|
|
||||||
$id,
|
|
||||||
trim($item['description'] ?? ''),
|
|
||||||
$item['quantity'] ?? 1,
|
|
||||||
trim($item['unit'] ?? ''),
|
|
||||||
$item['unit_price'] ?? 0,
|
|
||||||
$item['vat_rate'] ?? 21,
|
|
||||||
$item['position'] ?? $i,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poznamky lze editovat jen v issued/overdue stavu
|
|
||||||
if ($isDraft || $invoice['status'] === 'overdue') {
|
|
||||||
if (array_key_exists('notes', $input)) {
|
|
||||||
$updates[] = 'notes = ?';
|
|
||||||
$params[] = $input['notes'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('internal_notes', $input)) {
|
|
||||||
$updates[] = 'internal_notes = ?';
|
|
||||||
$params[] = $input['internal_notes'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($updates)) {
|
|
||||||
$updates[] = 'modified_at = NOW()';
|
|
||||||
$params[] = $id;
|
|
||||||
$sql = 'UPDATE invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'invoices_invoice',
|
|
||||||
$id,
|
|
||||||
['status' => $invoice['status']],
|
|
||||||
['status' => $newStatus ?? $invoice['status']],
|
|
||||||
"Aktualizována faktura '{$invoice['invoice_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Faktura byla aktualizována');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteInvoice(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM invoices WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$pdo->prepare('DELETE FROM invoice_items WHERE invoice_id = ?')->execute([$id]);
|
|
||||||
$pdo->prepare('DELETE FROM invoices WHERE id = ?')->execute([$id]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logDelete('invoices_invoice', $id, [
|
|
||||||
'invoice_number' => $invoice['invoice_number'],
|
|
||||||
'customer_id' => $invoice['customer_id'],
|
|
||||||
], "Smazána faktura '{$invoice['invoice_number']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Faktura byla smazána');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ require_once dirname(__DIR__) . '/config.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
|
require_once dirname(__DIR__) . '/includes/Mailer.php';
|
||||||
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
|
require_once dirname(__DIR__) . '/includes/LeaveNotification.php';
|
||||||
|
require_once __DIR__ . '/handlers/leave-requests-handlers.php';
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
@@ -76,462 +78,3 @@ try {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate number of business days between two dates (skip Sat/Sun)
|
|
||||||
*/
|
|
||||||
function calculateBusinessDays(string $dateFrom, string $dateTo): int
|
|
||||||
{
|
|
||||||
$start = new DateTime($dateFrom);
|
|
||||||
$end = new DateTime($dateTo);
|
|
||||||
$end->modify('+1 day'); // include the end date
|
|
||||||
|
|
||||||
$days = 0;
|
|
||||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
|
||||||
foreach ($period as $date) {
|
|
||||||
$dayOfWeek = (int)$date->format('N'); // 1=Mon, 7=Sun
|
|
||||||
if ($dayOfWeek <= 5) {
|
|
||||||
$days++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $days;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get leave balance for user (reuse logic from attendance.php)
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
function getLeaveBalanceForRequest(PDO $pdo, int $userId, ?int $year = null): array
|
|
||||||
{
|
|
||||||
$year = $year ?: (int)date('Y');
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM leave_balances WHERE user_id = ? AND year = ?');
|
|
||||||
$stmt->execute([$userId, $year]);
|
|
||||||
$balance = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$balance) {
|
|
||||||
return [
|
|
||||||
'vacation_total' => 160,
|
|
||||||
'vacation_used' => 0,
|
|
||||||
'vacation_remaining' => 160,
|
|
||||||
'sick_used' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'vacation_total' => (float)$balance['vacation_total'],
|
|
||||||
'vacation_used' => (float)$balance['vacation_used'],
|
|
||||||
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
|
||||||
'sick_used' => (float)$balance['sick_used'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get hours already locked in pending requests for vacation
|
|
||||||
*/
|
|
||||||
function getPendingVacationHours(PDO $pdo, int $userId, int $year): float
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT COALESCE(SUM(total_hours), 0) as pending_hours
|
|
||||||
FROM leave_requests
|
|
||||||
WHERE user_id = ? AND leave_type = 'vacation' AND status = 'pending'
|
|
||||||
AND YEAR(date_from) = ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId, $year]);
|
|
||||||
return (float)$stmt->fetchColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// GET Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Own leave requests
|
|
||||||
*/
|
|
||||||
function handleGetMyRequests(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT lr.*,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as reviewer_name
|
|
||||||
FROM leave_requests lr
|
|
||||||
LEFT JOIN users u ON lr.reviewer_id = u.id
|
|
||||||
WHERE lr.user_id = ?
|
|
||||||
ORDER BY lr.created_at DESC
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$requests = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse($requests);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - All pending requests (for approver)
|
|
||||||
*/
|
|
||||||
function handleGetPending(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT lr.*,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
|
||||||
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
|
||||||
FROM leave_requests lr
|
|
||||||
JOIN users u ON lr.user_id = u.id
|
|
||||||
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
|
||||||
WHERE lr.status = 'pending'
|
|
||||||
ORDER BY lr.created_at ASC
|
|
||||||
");
|
|
||||||
$stmt->execute();
|
|
||||||
$requests = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'requests' => $requests,
|
|
||||||
'count' => count($requests),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - All requests with filters (for approver)
|
|
||||||
*/
|
|
||||||
function handleGetAll(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$status = $_GET['status'] ?? '';
|
|
||||||
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
|
||||||
|
|
||||||
$where = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($status && in_array($status, ['pending', 'approved', 'rejected', 'cancelled'])) {
|
|
||||||
$where[] = 'lr.status = ?';
|
|
||||||
$params[] = $status;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($userId) {
|
|
||||||
$where[] = 'lr.user_id = ?';
|
|
||||||
$params[] = $userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$whereClause = $where ? 'WHERE ' . implode(' AND ', $where) : '';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT lr.*,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as employee_name,
|
|
||||||
CONCAT(rv.first_name, ' ', rv.last_name) as reviewer_name
|
|
||||||
FROM leave_requests lr
|
|
||||||
JOIN users u ON lr.user_id = u.id
|
|
||||||
LEFT JOIN users rv ON lr.reviewer_id = rv.id
|
|
||||||
$whereClause
|
|
||||||
ORDER BY lr.created_at DESC
|
|
||||||
LIMIT 200
|
|
||||||
");
|
|
||||||
$stmt->execute($params);
|
|
||||||
$requests = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse($requests);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// POST Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Submit new leave request
|
|
||||||
*/
|
|
||||||
function handleSubmitRequest(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$leaveType = $input['leave_type'] ?? '';
|
|
||||||
$dateFrom = $input['date_from'] ?? '';
|
|
||||||
$dateTo = $input['date_to'] ?? '';
|
|
||||||
$notes = trim($input['notes'] ?? '');
|
|
||||||
|
|
||||||
if (!$leaveType || !$dateFrom || !$dateTo) {
|
|
||||||
errorResponse('Vyplňte všechna povinná pole');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!in_array($leaveType, ['vacation', 'sick', 'unpaid'])) {
|
|
||||||
errorResponse('Neplatný typ nepřítomnosti');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate dates
|
|
||||||
$from = new DateTime($dateFrom);
|
|
||||||
$to = new DateTime($dateTo);
|
|
||||||
if ($to < $from) {
|
|
||||||
errorResponse('Datum "do" nesmí být před datem "od"');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate business days
|
|
||||||
$businessDays = calculateBusinessDays($dateFrom, $dateTo);
|
|
||||||
if ($businessDays === 0) {
|
|
||||||
errorResponse('Zvolené období neobsahuje žádné pracovní dny');
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalHours = $businessDays * 8;
|
|
||||||
|
|
||||||
// Check vacation balance
|
|
||||||
if ($leaveType === 'vacation') {
|
|
||||||
$year = (int)$from->format('Y');
|
|
||||||
$balance = getLeaveBalanceForRequest($pdo, $userId, $year);
|
|
||||||
$pendingHours = getPendingVacationHours($pdo, $userId, $year);
|
|
||||||
$availableHours = $balance['vacation_remaining'] - $pendingHours;
|
|
||||||
|
|
||||||
if ($availableHours < $totalHours) {
|
|
||||||
errorResponse(
|
|
||||||
"Nemáte dostatek hodin dovolené. Dostupné: {$availableHours}h"
|
|
||||||
. " (zbývá {$balance['vacation_remaining']}h, v čekajících žádostech: {$pendingHours}h),"
|
|
||||||
. " požadujete: {$totalHours}h."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check overlapping requests
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT id FROM leave_requests
|
|
||||||
WHERE user_id = ? AND status IN ('pending', 'approved')
|
|
||||||
AND date_from <= ? AND date_to >= ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId, $dateTo, $dateFrom]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('Pro toto období již existuje žádost o nepřítomnost');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert request
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
INSERT INTO leave_requests (user_id, leave_type, date_from, date_to, total_hours, total_days, notes, status)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')
|
|
||||||
");
|
|
||||||
$stmt->execute([$userId, $leaveType, $dateFrom, $dateTo, $totalHours, $businessDays, $notes ?: null]);
|
|
||||||
|
|
||||||
$requestId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
AuditLog::logCreate('leave_request', $requestId, [
|
|
||||||
'leave_type' => $leaveType,
|
|
||||||
'date_from' => $dateFrom,
|
|
||||||
'date_to' => $dateTo,
|
|
||||||
'total_days' => $businessDays,
|
|
||||||
'total_hours' => $totalHours,
|
|
||||||
], "Podána žádost o nepřítomnost: $leaveType ($dateFrom - $dateTo)");
|
|
||||||
|
|
||||||
// Send email notification
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$employeeName = $stmt->fetchColumn() ?: 'Neznámý';
|
|
||||||
|
|
||||||
LeaveNotification::notifyNewRequest([
|
|
||||||
'leave_type' => $leaveType,
|
|
||||||
'date_from' => $dateFrom,
|
|
||||||
'date_to' => $dateTo,
|
|
||||||
'total_days' => $businessDays,
|
|
||||||
'total_hours' => $totalHours,
|
|
||||||
'notes' => $notes,
|
|
||||||
], $employeeName);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
error_log('Leave notification error: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse(['id' => $requestId], 'Žádost byla odeslána ke schválení');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Cancel own pending request
|
|
||||||
*/
|
|
||||||
function handleCancelRequest(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$requestId = (int)($input['request_id'] ?? 0);
|
|
||||||
|
|
||||||
if (!$requestId) {
|
|
||||||
errorResponse('ID žádosti je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ? AND user_id = ?');
|
|
||||||
$stmt->execute([$requestId, $userId]);
|
|
||||||
$request = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$request) {
|
|
||||||
errorResponse('Žádost nebyla nalezena');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request['status'] !== 'pending') {
|
|
||||||
errorResponse('Lze zrušit pouze čekající žádosti');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("UPDATE leave_requests SET status = 'cancelled' WHERE id = ?");
|
|
||||||
$stmt->execute([$requestId]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'leave_request',
|
|
||||||
$requestId,
|
|
||||||
['status' => 'pending'],
|
|
||||||
['status' => 'cancelled'],
|
|
||||||
'Žádost o nepřítomnost zrušena zaměstnancem'
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Žádost byla zrušena');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Approve a leave request
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleApproveRequest(PDO $pdo, int $reviewerId, array $authData): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$requestId = (int)($input['request_id'] ?? 0);
|
|
||||||
|
|
||||||
if (!$requestId) {
|
|
||||||
errorResponse('ID žádosti je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
|
||||||
$stmt->execute([$requestId]);
|
|
||||||
$request = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$request) {
|
|
||||||
errorResponse('Žádost nebyla nalezena');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request['status'] !== 'pending') {
|
|
||||||
errorResponse('Lze schválit pouze čekající žádosti');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
|
||||||
errorResponse('Nemůžete schválit vlastní žádost', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-check vacation balance
|
|
||||||
if ($request['leave_type'] === 'vacation') {
|
|
||||||
$year = (int)date('Y', strtotime($request['date_from']));
|
|
||||||
$balance = getLeaveBalanceForRequest($pdo, (int)$request['user_id'], $year);
|
|
||||||
|
|
||||||
if ($balance['vacation_remaining'] < (float)$request['total_hours']) {
|
|
||||||
errorResponse(
|
|
||||||
"Zaměstnanec nemá dostatek hodin dovolené."
|
|
||||||
. " Zbývá: {$balance['vacation_remaining']}h, požadováno: {$request['total_hours']}h."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin transaction
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create attendance records for each business day
|
|
||||||
$start = new DateTime($request['date_from']);
|
|
||||||
$end = new DateTime($request['date_to']);
|
|
||||||
$end->modify('+1 day');
|
|
||||||
|
|
||||||
$period = new DatePeriod($start, new DateInterval('P1D'), $end);
|
|
||||||
$insertStmt = $pdo->prepare('
|
|
||||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
|
||||||
VALUES (?, ?, ?, 8, ?)
|
|
||||||
');
|
|
||||||
|
|
||||||
$leaveNote = "Schválená žádost #$requestId";
|
|
||||||
$totalBusinessDays = 0;
|
|
||||||
|
|
||||||
foreach ($period as $date) {
|
|
||||||
$dayOfWeek = (int)$date->format('N');
|
|
||||||
if ($dayOfWeek <= 5) {
|
|
||||||
$shiftDate = $date->format('Y-m-d');
|
|
||||||
$insertStmt->execute([
|
|
||||||
$request['user_id'],
|
|
||||||
$shiftDate,
|
|
||||||
$request['leave_type'],
|
|
||||||
$leaveNote,
|
|
||||||
]);
|
|
||||||
$totalBusinessDays++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update leave balance ONCE with total hours (was N queries, one per day)
|
|
||||||
if ($totalBusinessDays > 0) {
|
|
||||||
updateLeaveBalance(
|
|
||||||
$pdo,
|
|
||||||
(int)$request['user_id'],
|
|
||||||
$request['date_from'],
|
|
||||||
$request['leave_type'],
|
|
||||||
(float)($totalBusinessDays * 8)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update request status
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
UPDATE leave_requests
|
|
||||||
SET status = 'approved', reviewer_id = ?, reviewed_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$reviewerId, $requestId]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'leave_request',
|
|
||||||
$requestId,
|
|
||||||
['status' => 'pending'],
|
|
||||||
['status' => 'approved', 'reviewer_id' => $reviewerId],
|
|
||||||
'Žádost o nepřítomnost schválena'
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Žádost byla schválena');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
error_log('Approve request error: ' . $e->getMessage());
|
|
||||||
errorResponse('Chyba při schvalování žádosti', 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Reject a leave request
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleRejectRequest(PDO $pdo, int $reviewerId, array $authData): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$requestId = (int)($input['request_id'] ?? 0);
|
|
||||||
$note = trim($input['note'] ?? '');
|
|
||||||
|
|
||||||
if (!$requestId) {
|
|
||||||
errorResponse('ID žádosti je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$note) {
|
|
||||||
errorResponse('Důvod zamítnutí je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM leave_requests WHERE id = ?');
|
|
||||||
$stmt->execute([$requestId]);
|
|
||||||
$request = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$request) {
|
|
||||||
errorResponse('Žádost nebyla nalezena');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request['status'] !== 'pending') {
|
|
||||||
errorResponse('Lze zamítnout pouze čekající žádosti');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int)$request['user_id'] === $reviewerId && !($authData['user']['is_admin'] ?? false)) {
|
|
||||||
errorResponse('Nemůžete zamítnout vlastní žádost', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
UPDATE leave_requests
|
|
||||||
SET status = 'rejected', reviewer_id = ?, reviewer_note = ?, reviewed_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$reviewerId, $note, $requestId]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'leave_request',
|
|
||||||
$requestId,
|
|
||||||
['status' => 'pending'],
|
|
||||||
['status' => 'rejected', 'reviewer_id' => $reviewerId, 'reviewer_note' => $note],
|
|
||||||
"Žádost o nepřítomnost zamítnuta: $note"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Žádost byla zamítnuta');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/offers-templates-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -98,263 +99,3 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Item Templates ---
|
// --- Item Templates ---
|
||||||
|
|
||||||
function handleGetItemTemplates(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('SELECT * FROM item_templates ORDER BY category, name');
|
|
||||||
successResponse(['templates' => $stmt->fetchAll()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveItemTemplate(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
if (empty($input['name'])) {
|
|
||||||
errorResponse('Název šablony je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = isset($input['id']) ? (int)$input['id'] : null;
|
|
||||||
|
|
||||||
if ($id) {
|
|
||||||
// Update
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE item_templates SET
|
|
||||||
name = ?, description = ?, default_price = ?, category = ?,
|
|
||||||
modified_at = NOW(), sync_version = sync_version + 1
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'],
|
|
||||||
$input['description'] ?? '',
|
|
||||||
$input['default_price'] ?? 0,
|
|
||||||
$input['category'] ?? '',
|
|
||||||
$id,
|
|
||||||
]);
|
|
||||||
successResponse(null, 'Šablona byla aktualizována');
|
|
||||||
} else {
|
|
||||||
// Create
|
|
||||||
$uuid = sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0x0fff) | 0x4000,
|
|
||||||
random_int(0, 0x3fff) | 0x8000,
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff)
|
|
||||||
);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO item_templates (name, description, default_price, category, uuid, modified_at, sync_version)
|
|
||||||
VALUES (?, ?, ?, ?, ?, NOW(), 1)
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'],
|
|
||||||
$input['description'] ?? '',
|
|
||||||
$input['default_price'] ?? 0,
|
|
||||||
$input['category'] ?? '',
|
|
||||||
$uuid,
|
|
||||||
]);
|
|
||||||
$newId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
AuditLog::logCreate(
|
|
||||||
'offers_item_template',
|
|
||||||
(int)$newId,
|
|
||||||
['name' => $input['name']],
|
|
||||||
"Vytvořena šablona položky '{$input['name']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(['id' => $newId], 'Šablona byla vytvořena');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteItemTemplate(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT name FROM item_templates WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$template = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$template) {
|
|
||||||
errorResponse('Šablona nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM item_templates WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
|
|
||||||
AuditLog::logDelete(
|
|
||||||
'offers_item_template',
|
|
||||||
$id,
|
|
||||||
['name' => $template['name']],
|
|
||||||
"Smazána šablona položky '{$template['name']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Šablona byla smazána');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scope Templates ---
|
|
||||||
|
|
||||||
function handleGetScopeTemplates(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('SELECT * FROM scope_templates ORDER BY name');
|
|
||||||
successResponse(['templates' => $stmt->fetchAll()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetScopeDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM scope_templates WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$template = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$template) {
|
|
||||||
errorResponse('Šablona nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM scope_template_sections WHERE scope_template_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$template['sections'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse($template);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveScopeTemplate(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
if (empty($input['name'])) {
|
|
||||||
errorResponse('Název šablony je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = isset($input['id']) ? (int)$input['id'] : null;
|
|
||||||
$sections = $input['sections'] ?? [];
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
if ($id) {
|
|
||||||
// Update template
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE scope_templates SET
|
|
||||||
name = ?,
|
|
||||||
title = ?,
|
|
||||||
description = ?,
|
|
||||||
modified_at = NOW(),
|
|
||||||
sync_version = sync_version + 1
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'],
|
|
||||||
$input['title'] ?? '',
|
|
||||||
$input['description'] ?? '',
|
|
||||||
$id,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
// Create template
|
|
||||||
$uuid = sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0x0fff) | 0x4000,
|
|
||||||
random_int(0, 0x3fff) | 0x8000,
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff)
|
|
||||||
);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO scope_templates (name, title, description, uuid, modified_at, sync_version)
|
|
||||||
VALUES (?, ?, ?, ?, NOW(), 1)
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$input['name'],
|
|
||||||
$input['title'] ?? '',
|
|
||||||
$input['description'] ?? '',
|
|
||||||
$uuid,
|
|
||||||
]);
|
|
||||||
$id = (int)$pdo->lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete existing sections and re-insert
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO scope_template_sections
|
|
||||||
(scope_template_id, title, title_cz, content, position, uuid, modified_at, sync_version)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), 1)
|
|
||||||
');
|
|
||||||
|
|
||||||
foreach ($sections as $i => $section) {
|
|
||||||
$sectionUuid = sprintf(
|
|
||||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0x0fff) | 0x4000,
|
|
||||||
random_int(0, 0x3fff) | 0x8000,
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff),
|
|
||||||
random_int(0, 0xffff)
|
|
||||||
);
|
|
||||||
$stmt->execute([
|
|
||||||
$id,
|
|
||||||
$section['title'] ?? '',
|
|
||||||
$section['title_cz'] ?? '',
|
|
||||||
$section['content'] ?? '',
|
|
||||||
$i + 1,
|
|
||||||
$sectionUuid,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logCreate(
|
|
||||||
'offers_scope_template',
|
|
||||||
$id,
|
|
||||||
['name' => $input['name']],
|
|
||||||
"Uložena šablona rozsahu '{$input['name']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(['id' => $id], 'Šablona rozsahu byla uložena');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteScopeTemplate(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT name FROM scope_templates WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$template = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$template) {
|
|
||||||
errorResponse('Šablona nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
// Delete sections
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM scope_template_sections WHERE scope_template_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// Delete template
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM scope_templates WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logDelete(
|
|
||||||
'offers_scope_template',
|
|
||||||
$id,
|
|
||||||
['name' => $template['name']],
|
|
||||||
"Smazána šablona rozsahu '{$template['name']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Šablona rozsahu byla smazána');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/offers-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -92,588 +93,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGetList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$search = trim($_GET['search'] ?? '');
|
|
||||||
$sort = $_GET['sort'] ?? 'created_at';
|
|
||||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
||||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
||||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
|
||||||
|
|
||||||
$sortMap = [
|
|
||||||
'Date' => 'q.created_at',
|
|
||||||
'CreatedAt' => 'q.created_at',
|
|
||||||
'created_at' => 'q.created_at',
|
|
||||||
'QuotationNumber' => 'q.quotation_number',
|
|
||||||
'quotation_number' => 'q.quotation_number',
|
|
||||||
'ProjectCode' => 'q.project_code',
|
|
||||||
'project_code' => 'q.project_code',
|
|
||||||
'ValidUntil' => 'q.valid_until',
|
|
||||||
'valid_until' => 'q.valid_until',
|
|
||||||
'Currency' => 'q.currency',
|
|
||||||
'currency' => 'q.currency',
|
|
||||||
];
|
|
||||||
if (!isset($sortMap[$sort])) {
|
|
||||||
errorResponse('Neplatný parametr řazení', 400);
|
|
||||||
}
|
|
||||||
$sortCol = $sortMap[$sort];
|
|
||||||
|
|
||||||
$where = 'WHERE 1=1';
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($search) {
|
|
||||||
$search = mb_substr($search, 0, 100);
|
|
||||||
$where .= ' AND (q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
|
||||||
$searchParam = "%{$search}%";
|
|
||||||
$params = [$searchParam, $searchParam, $searchParam];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Celkovy pocet pro pagination
|
|
||||||
$countSql = "
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM quotations q
|
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
|
||||||
$where
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($countSql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$total = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT q.id, q.quotation_number, q.project_code, q.created_at, q.valid_until,
|
|
||||||
q.currency, q.language, q.apply_vat, q.vat_rate, q.exchange_rate,
|
|
||||||
q.customer_id, q.order_id, q.status,
|
|
||||||
c.name as customer_name,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN qi.is_included_in_total THEN qi.quantity * qi.unit_price ELSE 0 END), 0)
|
|
||||||
FROM quotation_items qi WHERE qi.quotation_id = q.id) as total
|
|
||||||
FROM quotations q
|
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
|
||||||
$where
|
|
||||||
ORDER BY $sortCol $order
|
|
||||||
LIMIT $perPage OFFSET $offset
|
|
||||||
";
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$quotations = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'quotations' => $quotations,
|
|
||||||
'total' => $total,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT q.*, c.name as customer_name
|
|
||||||
FROM quotations q
|
|
||||||
LEFT JOIN customers c ON q.customer_id = c.id
|
|
||||||
WHERE q.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$quotation = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$quotation) {
|
|
||||||
errorResponse('Nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get items
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM quotation_items
|
|
||||||
WHERE quotation_id = ?
|
|
||||||
ORDER BY position
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$quotation['items'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get scope sections
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM scope_sections
|
|
||||||
WHERE quotation_id = ?
|
|
||||||
ORDER BY position
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$quotation['sections'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get customer
|
|
||||||
if ($quotation['customer_id']) {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'SELECT id, name, company_id, vat_id, street, city, postal_code, country, custom_fields
|
|
||||||
FROM customers WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$quotation['customer_id']]);
|
|
||||||
$quotation['customer'] = $stmt->fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get linked order info
|
|
||||||
if ($quotation['order_id']) {
|
|
||||||
$stmt = $pdo->prepare('SELECT id, order_number, status FROM orders WHERE id = ?');
|
|
||||||
$stmt->execute([$quotation['order_id']]);
|
|
||||||
$quotation['order'] = $stmt->fetch() ?: null;
|
|
||||||
} else {
|
|
||||||
$quotation['order'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse($quotation);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetNextNumber(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
|
||||||
if (!$settings) {
|
|
||||||
errorResponse('Nastavení firmy nenalezeno');
|
|
||||||
}
|
|
||||||
|
|
||||||
$year = date('Y');
|
|
||||||
$prefix = $settings['quotation_prefix'] ?: 'N';
|
|
||||||
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
|
||||||
|
|
||||||
$formatted = sprintf('%s/%s/%03d', $year, $prefix, $number);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'number' => $formatted,
|
|
||||||
'raw_number' => $number,
|
|
||||||
'prefix' => $prefix,
|
|
||||||
'year' => $year,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMaxQuotationNumber(PDO $pdo, string $year, string $prefix): int
|
|
||||||
{
|
|
||||||
$likePattern = "{$year}/{$prefix}/%";
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0)
|
|
||||||
FROM quotations
|
|
||||||
WHERE quotation_number LIKE ?
|
|
||||||
");
|
|
||||||
$stmt->execute([$likePattern]);
|
|
||||||
return (int) $stmt->fetchColumn();
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateNextNumber(PDO $pdo): string
|
|
||||||
{
|
|
||||||
$settings = $pdo->query('SELECT quotation_prefix FROM company_settings LIMIT 1')->fetch();
|
|
||||||
|
|
||||||
$year = date('Y');
|
|
||||||
$prefix = $settings['quotation_prefix'] ?: 'N';
|
|
||||||
$number = getMaxQuotationNumber($pdo, $year, $prefix) + 1;
|
|
||||||
|
|
||||||
return sprintf('%s/%s/%03d', $year, $prefix, $number);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $q */
|
|
||||||
function validateQuotationInput(array $q): void
|
|
||||||
{
|
|
||||||
if (empty($q['customer_id'])) {
|
|
||||||
errorResponse('Vyberte zákazníka');
|
|
||||||
}
|
|
||||||
if (empty($q['created_at'])) {
|
|
||||||
errorResponse('Zadejte datum vytvoření');
|
|
||||||
}
|
|
||||||
if (empty($q['valid_until'])) {
|
|
||||||
errorResponse('Zadejte datum platnosti');
|
|
||||||
}
|
|
||||||
if (!empty($q['created_at']) && !empty($q['valid_until']) && $q['valid_until'] < $q['created_at']) {
|
|
||||||
errorResponse('Datum platnosti nesmí být před datem vytvoření');
|
|
||||||
}
|
|
||||||
if (empty($q['currency'])) {
|
|
||||||
errorResponse('Vyberte měnu');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace formatu dat
|
|
||||||
foreach (['created_at', 'valid_until'] as $dateField) {
|
|
||||||
if (!empty($q[$dateField]) && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $q[$dateField])) {
|
|
||||||
errorResponse("Neplatný formát data: $dateField");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Validace meny a jazyka
|
|
||||||
if (!in_array($q['currency'] ?? '', ['EUR', 'USD', 'CZK', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
if (!empty($q['language']) && !in_array($q['language'], ['EN', 'CZ'])) {
|
|
||||||
errorResponse('Neplatný jazyk');
|
|
||||||
}
|
|
||||||
// Validace DPH
|
|
||||||
if (isset($q['vat_rate'])) {
|
|
||||||
$rate = floatval($q['vat_rate']);
|
|
||||||
if ($rate < 0 || $rate > 100) {
|
|
||||||
errorResponse('Sazba DPH musí být mezi 0 a 100');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Delkove limity
|
|
||||||
if (!empty($q['project_code']) && mb_strlen($q['project_code']) > 100) {
|
|
||||||
errorResponse('Kód projektu je příliš dlouhý (max 100 znaků)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateOffer(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
$quotation = $input['quotation'] ?? $input;
|
|
||||||
$items = $input['items'] ?? [];
|
|
||||||
$sections = $input['sections'] ?? [];
|
|
||||||
|
|
||||||
validateQuotationInput($quotation);
|
|
||||||
|
|
||||||
// Serialize number generation across concurrent requests
|
|
||||||
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
|
||||||
if (!$locked) {
|
|
||||||
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$quotationNumber = generateNextNumber($pdo);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO quotations (
|
|
||||||
quotation_number, project_code, customer_id, created_at, valid_until,
|
|
||||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
|
||||||
scope_title, scope_description, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
$quotationNumber,
|
|
||||||
$quotation['project_code'] ?? '',
|
|
||||||
$quotation['customer_id'] ? (int)$quotation['customer_id'] : null,
|
|
||||||
$quotation['created_at'] ?? date('Y-m-d H:i:s'),
|
|
||||||
$quotation['valid_until'] ?? date('Y-m-d H:i:s', strtotime('+30 days')),
|
|
||||||
$quotation['currency'] ?? 'EUR',
|
|
||||||
$quotation['language'] ?? 'EN',
|
|
||||||
$quotation['vat_rate'] ?? 21,
|
|
||||||
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : 0,
|
|
||||||
$quotation['exchange_rate'] ?? null,
|
|
||||||
$quotation['scope_title'] ?? '',
|
|
||||||
$quotation['scope_description'] ?? '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$quotationId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
saveItems($pdo, $quotationId, $items);
|
|
||||||
saveSections($pdo, $quotationId, $sections);
|
|
||||||
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
|
||||||
|
|
||||||
AuditLog::logCreate('offers_quotation', $quotationId, [
|
|
||||||
'quotation_number' => $quotationNumber,
|
|
||||||
'project_code' => $quotation['project_code'] ?? '',
|
|
||||||
], "Vytvořena nabídka '$quotationNumber'");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'id' => $quotationId,
|
|
||||||
'number' => $quotationNumber,
|
|
||||||
], 'Nabídka byla vytvořena');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateOffer(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$existing = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$existing) {
|
|
||||||
errorResponse('Nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($existing['status'] === 'invalidated') {
|
|
||||||
errorResponse('Zneplatněnou nabídku nelze upravovat', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$quotation = $input['quotation'] ?? $input;
|
|
||||||
$items = $input['items'] ?? [];
|
|
||||||
$sections = $input['sections'] ?? [];
|
|
||||||
|
|
||||||
validateQuotationInput($quotation);
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE quotations SET
|
|
||||||
project_code = ?,
|
|
||||||
customer_id = ?,
|
|
||||||
created_at = ?,
|
|
||||||
valid_until = ?,
|
|
||||||
currency = ?,
|
|
||||||
language = ?,
|
|
||||||
vat_rate = ?,
|
|
||||||
apply_vat = ?,
|
|
||||||
exchange_rate = ?,
|
|
||||||
scope_title = ?,
|
|
||||||
scope_description = ?,
|
|
||||||
modified_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
$quotation['project_code'] ?? $existing['project_code'],
|
|
||||||
isset($quotation['customer_id'])
|
|
||||||
? ($quotation['customer_id'] ? (int)$quotation['customer_id'] : null)
|
|
||||||
: $existing['customer_id'],
|
|
||||||
$quotation['created_at'] ?? $existing['created_at'],
|
|
||||||
$quotation['valid_until'] ?? $existing['valid_until'],
|
|
||||||
$quotation['currency'] ?? $existing['currency'],
|
|
||||||
$quotation['language'] ?? $existing['language'],
|
|
||||||
$quotation['vat_rate'] ?? $existing['vat_rate'],
|
|
||||||
isset($quotation['apply_vat']) ? ($quotation['apply_vat'] ? 1 : 0) : $existing['apply_vat'],
|
|
||||||
array_key_exists('exchange_rate', $quotation) ? $quotation['exchange_rate'] : $existing['exchange_rate'],
|
|
||||||
$quotation['scope_title'] ?? $existing['scope_title'],
|
|
||||||
$quotation['scope_description'] ?? $existing['scope_description'],
|
|
||||||
$id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Replace items
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
saveItems($pdo, $id, $items);
|
|
||||||
|
|
||||||
// Replace sections
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
saveSections($pdo, $id, $sections);
|
|
||||||
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'offers_quotation',
|
|
||||||
$id,
|
|
||||||
['quotation_number' => $existing['quotation_number']],
|
|
||||||
['project_code' => $quotation['project_code'] ?? $existing['project_code']],
|
|
||||||
"Upravena nabídka '{$existing['quotation_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Nabídka byla aktualizována');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDuplicate(PDO $pdo, int $sourceId): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$sourceId]);
|
|
||||||
$source = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$source) {
|
|
||||||
errorResponse('Zdrojová nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$sourceId]);
|
|
||||||
$sourceItems = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$sourceId]);
|
|
||||||
$sourceSections = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$locked = $pdo->query("SELECT GET_LOCK('boha_quotation_number', 5)")->fetchColumn();
|
|
||||||
if (!$locked) {
|
|
||||||
errorResponse('Nepodařilo se získat zámek pro číslo nabídky, zkuste to znovu', 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$newNumber = generateNextNumber($pdo);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO quotations (
|
|
||||||
quotation_number, project_code, customer_id, created_at, valid_until,
|
|
||||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
|
||||||
scope_title, scope_description, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
$newNumber,
|
|
||||||
$source['project_code'],
|
|
||||||
$source['customer_id'],
|
|
||||||
date('Y-m-d H:i:s'),
|
|
||||||
date('Y-m-d H:i:s', strtotime('+30 days')),
|
|
||||||
$source['currency'],
|
|
||||||
$source['language'],
|
|
||||||
$source['vat_rate'],
|
|
||||||
$source['apply_vat'],
|
|
||||||
$source['exchange_rate'],
|
|
||||||
$source['scope_title'],
|
|
||||||
$source['scope_description'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$newId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
$items = array_map(function ($item) {
|
|
||||||
return [
|
|
||||||
'description' => $item['description'],
|
|
||||||
'item_description' => $item['item_description'],
|
|
||||||
'quantity' => $item['quantity'],
|
|
||||||
'unit_price' => $item['unit_price'],
|
|
||||||
'is_included_in_total' => $item['is_included_in_total'],
|
|
||||||
'position' => $item['position'],
|
|
||||||
];
|
|
||||||
}, $sourceItems);
|
|
||||||
saveItems($pdo, $newId, $items);
|
|
||||||
|
|
||||||
$sections = array_map(function ($section) {
|
|
||||||
return [
|
|
||||||
'title' => $section['title'],
|
|
||||||
'title_cz' => $section['title_cz'],
|
|
||||||
'content' => $section['content'],
|
|
||||||
'position' => $section['position'],
|
|
||||||
];
|
|
||||||
}, $sourceSections);
|
|
||||||
saveSections($pdo, $newId, $sections);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
|
||||||
|
|
||||||
AuditLog::logCreate('offers_quotation', $newId, [
|
|
||||||
'quotation_number' => $newNumber,
|
|
||||||
'duplicated_from' => $source['quotation_number'],
|
|
||||||
], "Duplikována nabídka '{$source['quotation_number']}' jako '$newNumber'");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'id' => $newId,
|
|
||||||
'number' => $newNumber,
|
|
||||||
], 'Nabídka byla duplikována');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_quotation_number')");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInvalidateOffer(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT quotation_number, status, order_id FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$quotation = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$quotation) {
|
|
||||||
errorResponse('Nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quotation['status'] === 'invalidated') {
|
|
||||||
errorResponse('Nabídka je již zneplatněna', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quotation['order_id']) {
|
|
||||||
errorResponse('Nabídku s objednávkou nelze zneplatnit', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE quotations SET status = ?, modified_at = NOW() WHERE id = ?');
|
|
||||||
$stmt->execute(['invalidated', $id]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'offers_quotation',
|
|
||||||
$id,
|
|
||||||
['status' => 'active'],
|
|
||||||
['status' => 'invalidated'],
|
|
||||||
"Zneplatněna nabídka '{$quotation['quotation_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Nabídka byla zneplatněna');
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteQuotation(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT quotation_number FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$quotation = $stmt->fetch();
|
|
||||||
|
|
||||||
|
|
||||||
if (!$quotation) {
|
|
||||||
errorResponse('Nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM quotation_items WHERE quotation_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM scope_sections WHERE quotation_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logDelete('offers_quotation', $id, [
|
|
||||||
'quotation_number' => $quotation['quotation_number'],
|
|
||||||
], "Smazána nabídka '{$quotation['quotation_number']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Nabídka byla smazána');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
/** @param list<array<string, mixed>> $items */
|
|
||||||
function saveItems(PDO $pdo, int $quotationId, array $items): void
|
|
||||||
{
|
|
||||||
if (empty($items)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO quotation_items (
|
|
||||||
quotation_id, description, item_description, quantity, unit,
|
|
||||||
unit_price, is_included_in_total, position, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
|
|
||||||
foreach ($items as $i => $item) {
|
|
||||||
$stmt->execute([
|
|
||||||
$quotationId,
|
|
||||||
$item['description'] ?? '',
|
|
||||||
$item['item_description'] ?? '',
|
|
||||||
$item['quantity'] ?? 1,
|
|
||||||
$item['unit'] ?? '',
|
|
||||||
$item['unit_price'] ?? 0,
|
|
||||||
isset($item['is_included_in_total']) ? ($item['is_included_in_total'] ? 1 : 0) : 1,
|
|
||||||
$item['position'] ?? ($i + 1),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param list<array<string, mixed>> $sections */
|
|
||||||
function saveSections(PDO $pdo, int $quotationId, array $sections): void
|
|
||||||
{
|
|
||||||
if (empty($sections)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO scope_sections (
|
|
||||||
quotation_id, title, title_cz, content, position, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
|
|
||||||
foreach ($sections as $i => $section) {
|
|
||||||
$stmt->execute([
|
|
||||||
$quotationId,
|
|
||||||
$section['title'] ?? '',
|
|
||||||
$section['title_cz'] ?? '',
|
|
||||||
$section['content'] ?? '',
|
|
||||||
$section['position'] ?? ($i + 1),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/orders-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -88,528 +89,3 @@ try {
|
|||||||
// --- Valid status transitions ---
|
// --- Valid status transitions ---
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
function getValidTransitions(string $currentStatus): array
|
|
||||||
{
|
|
||||||
$map = [
|
|
||||||
'prijata' => ['v_realizaci', 'stornovana'],
|
|
||||||
'v_realizaci' => ['dokoncena', 'stornovana'],
|
|
||||||
'dokoncena' => [],
|
|
||||||
'stornovana' => [],
|
|
||||||
];
|
|
||||||
return $map[$currentStatus] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Number generation ---
|
|
||||||
|
|
||||||
function generateOrderNumber(PDO $pdo): string
|
|
||||||
{
|
|
||||||
return generateSharedNumber($pdo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Handlers ---
|
|
||||||
|
|
||||||
function handleGetList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$search = trim($_GET['search'] ?? '');
|
|
||||||
$sort = $_GET['sort'] ?? 'created_at';
|
|
||||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
||||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
||||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
|
||||||
|
|
||||||
$sortMap = [
|
|
||||||
'OrderNumber' => 'o.order_number',
|
|
||||||
'order_number' => 'o.order_number',
|
|
||||||
'CreatedAt' => 'o.created_at',
|
|
||||||
'created_at' => 'o.created_at',
|
|
||||||
'Status' => 'o.status',
|
|
||||||
'status' => 'o.status',
|
|
||||||
'Currency' => 'o.currency',
|
|
||||||
'currency' => 'o.currency',
|
|
||||||
];
|
|
||||||
if (!isset($sortMap[$sort])) {
|
|
||||||
errorResponse('Neplatný parametr řazení', 400);
|
|
||||||
}
|
|
||||||
$sortCol = $sortMap[$sort];
|
|
||||||
|
|
||||||
$where = 'WHERE 1=1';
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($search) {
|
|
||||||
$search = mb_substr($search, 0, 100);
|
|
||||||
$where .= ' AND (o.order_number LIKE ? OR q.quotation_number LIKE ? OR q.project_code LIKE ? OR c.name LIKE ?)';
|
|
||||||
$searchParam = "%{$search}%";
|
|
||||||
$params = [$searchParam, $searchParam, $searchParam, $searchParam];
|
|
||||||
}
|
|
||||||
|
|
||||||
$countSql = "
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
|
||||||
LEFT JOIN customers c ON o.customer_id = c.id
|
|
||||||
$where
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($countSql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$total = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT o.id, o.order_number, o.quotation_id, o.status, o.currency,
|
|
||||||
o.created_at, o.apply_vat, o.vat_rate,
|
|
||||||
q.quotation_number, q.project_code,
|
|
||||||
c.name as customer_name,
|
|
||||||
(SELECT COALESCE(SUM(CASE WHEN oi.is_included_in_total THEN oi.quantity * oi.unit_price ELSE 0 END), 0)
|
|
||||||
FROM order_items oi WHERE oi.order_id = o.id) as total,
|
|
||||||
(SELECT inv.id FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_id,
|
|
||||||
(SELECT inv.invoice_number FROM invoices inv WHERE inv.order_id = o.id LIMIT 1) as invoice_number
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
|
||||||
LEFT JOIN customers c ON o.customer_id = c.id
|
|
||||||
$where
|
|
||||||
ORDER BY $sortCol $order
|
|
||||||
LIMIT $perPage OFFSET $offset
|
|
||||||
";
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$orders = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'orders' => $orders,
|
|
||||||
'total' => $total,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
// BLOB vynechany - stahuje se pres action=attachment
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT o.id, o.order_number, o.customer_order_number, o.attachment_name,
|
|
||||||
o.quotation_id, o.customer_id, o.status, o.currency, o.language,
|
|
||||||
o.vat_rate, o.apply_vat, o.exchange_rate, o.scope_title, o.scope_description,
|
|
||||||
o.notes, o.created_at, o.modified_at,
|
|
||||||
q.quotation_number, q.project_code,
|
|
||||||
c.name as customer_name
|
|
||||||
FROM orders o
|
|
||||||
LEFT JOIN quotations q ON o.quotation_id = q.id
|
|
||||||
LEFT JOIN customers c ON o.customer_id = c.id
|
|
||||||
WHERE o.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$order) {
|
|
||||||
errorResponse('Objednávka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get items
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM order_items WHERE order_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order['items'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get sections
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM order_sections WHERE order_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order['sections'] = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get customer
|
|
||||||
if ($order['customer_id']) {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'SELECT id, name, company_id, vat_id, street, city,
|
|
||||||
postal_code, country, custom_fields
|
|
||||||
FROM customers WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$order['customer_id']]);
|
|
||||||
$order['customer'] = $stmt->fetch();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get linked project
|
|
||||||
$stmt = $pdo->prepare('SELECT id, project_number, name, status FROM projects WHERE order_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order['project'] = $stmt->fetch() ?: null;
|
|
||||||
|
|
||||||
// Get linked invoice
|
|
||||||
$stmt = $pdo->prepare('SELECT id, invoice_number, status FROM invoices WHERE order_id = ? LIMIT 1');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order['invoice'] = $stmt->fetch() ?: null;
|
|
||||||
|
|
||||||
// Valid transitions
|
|
||||||
$order['valid_transitions'] = getValidTransitions($order['status']);
|
|
||||||
|
|
||||||
successResponse($order);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetAttachment(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT attachment_data, attachment_name FROM orders WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$row = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$row || !$row['attachment_data']) {
|
|
||||||
errorResponse('Příloha nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
||||||
$mime = $finfo->buffer($row['attachment_data']);
|
|
||||||
if ($mime !== 'application/pdf') {
|
|
||||||
errorResponse('Příloha není platný PDF soubor', 415);
|
|
||||||
}
|
|
||||||
|
|
||||||
header_remove('Content-Type');
|
|
||||||
header('Content-Type: application/pdf');
|
|
||||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['attachment_name'] ?: 'priloha.pdf'));
|
|
||||||
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
|
||||||
header('Content-Length: ' . strlen($row['attachment_data']));
|
|
||||||
echo $row['attachment_data'];
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateOrder(PDO $pdo): void
|
|
||||||
{
|
|
||||||
// Podporuje JSON i FormData (kvuli nahravani prilohy)
|
|
||||||
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
|
|
||||||
if (str_contains($contentType, 'multipart/form-data')) {
|
|
||||||
$quotationId = (int)($_POST['quotationId'] ?? 0);
|
|
||||||
$customerOrderNumber = trim($_POST['customerOrderNumber'] ?? '');
|
|
||||||
} else {
|
|
||||||
$input = getJsonInput();
|
|
||||||
$quotationId = (int)($input['quotationId'] ?? 0);
|
|
||||||
$customerOrderNumber = trim($input['customerOrderNumber'] ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$quotationId) {
|
|
||||||
errorResponse('ID nabídky je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($customerOrderNumber === '') {
|
|
||||||
errorResponse('Číslo objednávky zákazníka je povinné');
|
|
||||||
}
|
|
||||||
if (mb_strlen($customerOrderNumber) > 100) {
|
|
||||||
errorResponse('Číslo objednávky zákazníka je příliš dlouhé (max 100 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace prilohy
|
|
||||||
$attachmentData = null;
|
|
||||||
$attachmentName = null;
|
|
||||||
if (!empty($_FILES['attachment']['tmp_name'])) {
|
|
||||||
$file = $_FILES['attachment'];
|
|
||||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
||||||
errorResponse('Chyba při nahrávání souboru');
|
|
||||||
}
|
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
||||||
$mime = $finfo->file($file['tmp_name']);
|
|
||||||
if ($mime !== 'application/pdf') {
|
|
||||||
errorResponse('Příloha musí být ve formátu PDF');
|
|
||||||
}
|
|
||||||
if ($file['size'] > 10 * 1024 * 1024) {
|
|
||||||
errorResponse('Příloha nesmí být větší než 10 MB');
|
|
||||||
}
|
|
||||||
$attachmentData = file_get_contents($file['tmp_name']);
|
|
||||||
$attachmentName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name']));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify quotation exists and has no order yet
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM quotations WHERE id = ?');
|
|
||||||
$stmt->execute([$quotationId]);
|
|
||||||
$quotation = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$quotation) {
|
|
||||||
errorResponse('Nabídka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($quotation['order_id']) {
|
|
||||||
errorResponse('Tato nabídka již má objednávku');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get quotation items and sections
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM quotation_items WHERE quotation_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$quotationId]);
|
|
||||||
$quotationItems = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM scope_sections WHERE quotation_id = ? ORDER BY position');
|
|
||||||
$stmt->execute([$quotationId]);
|
|
||||||
$quotationSections = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Lock for concurrent number generation
|
|
||||||
$locked = $pdo->query("SELECT GET_LOCK('boha_order_number', 5)")->fetchColumn();
|
|
||||||
if (!$locked) {
|
|
||||||
errorResponse('Nepodařilo se získat zámek pro číslo objednávky, zkuste to znovu', 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$orderNumber = generateOrderNumber($pdo);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
INSERT INTO orders (
|
|
||||||
order_number, customer_order_number, attachment_data, attachment_name,
|
|
||||||
quotation_id, customer_id, status,
|
|
||||||
currency, language, vat_rate, apply_vat, exchange_rate,
|
|
||||||
scope_title, scope_description, created_at, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, 'prijata', ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
$orderNumber,
|
|
||||||
$customerOrderNumber,
|
|
||||||
$attachmentData,
|
|
||||||
$attachmentName,
|
|
||||||
$quotationId,
|
|
||||||
$quotation['customer_id'],
|
|
||||||
$quotation['currency'] ?? 'EUR',
|
|
||||||
$quotation['language'] ?? 'EN',
|
|
||||||
$quotation['vat_rate'] ?? 0,
|
|
||||||
$quotation['apply_vat'] ?? 0,
|
|
||||||
$quotation['exchange_rate'],
|
|
||||||
$quotation['scope_title'] ?? '',
|
|
||||||
$quotation['scope_description'] ?? '',
|
|
||||||
]);
|
|
||||||
$orderId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Copy items
|
|
||||||
if (!empty($quotationItems)) {
|
|
||||||
$itemStmt = $pdo->prepare('
|
|
||||||
INSERT INTO order_items (
|
|
||||||
order_id, description, item_description, quantity, unit,
|
|
||||||
unit_price, is_included_in_total, position, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
foreach ($quotationItems as $item) {
|
|
||||||
$itemStmt->execute([
|
|
||||||
$orderId,
|
|
||||||
$item['description'] ?? '',
|
|
||||||
$item['item_description'] ?? '',
|
|
||||||
$item['quantity'] ?? 1,
|
|
||||||
$item['unit'] ?? '',
|
|
||||||
$item['unit_price'] ?? 0,
|
|
||||||
$item['is_included_in_total'] ?? 1,
|
|
||||||
$item['position'] ?? 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy sections
|
|
||||||
if (!empty($quotationSections)) {
|
|
||||||
$sectionStmt = $pdo->prepare('
|
|
||||||
INSERT INTO order_sections (
|
|
||||||
order_id, title, title_cz, content, position, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
foreach ($quotationSections as $section) {
|
|
||||||
$sectionStmt->execute([
|
|
||||||
$orderId,
|
|
||||||
$section['title'] ?? '',
|
|
||||||
$section['title_cz'] ?? '',
|
|
||||||
$section['content'] ?? '',
|
|
||||||
$section['position'] ?? 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project with same number
|
|
||||||
$projectName = $quotation['project_code'] ?: ($quotation['customer_name'] ?? 'Projekt ' . $orderNumber);
|
|
||||||
// Need customer name
|
|
||||||
if (!$quotation['project_code'] && $quotation['customer_id']) {
|
|
||||||
$custStmt = $pdo->prepare('SELECT name FROM customers WHERE id = ?');
|
|
||||||
$custStmt->execute([$quotation['customer_id']]);
|
|
||||||
$custName = $custStmt->fetchColumn();
|
|
||||||
if ($custName) {
|
|
||||||
$projectName = $custName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
INSERT INTO projects (
|
|
||||||
project_number, name, customer_id, quotation_id, order_id,
|
|
||||||
status, start_date, created_at, modified_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 'aktivni', CURDATE(), NOW(), NOW())
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
$orderNumber,
|
|
||||||
$projectName,
|
|
||||||
$quotation['customer_id'],
|
|
||||||
$quotationId,
|
|
||||||
$orderId,
|
|
||||||
]);
|
|
||||||
$projectId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Update quotation with back-reference
|
|
||||||
$stmt = $pdo->prepare('UPDATE quotations SET order_id = ?, modified_at = NOW() WHERE id = ?');
|
|
||||||
$stmt->execute([$orderId, $quotationId]);
|
|
||||||
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
|
||||||
|
|
||||||
AuditLog::logCreate('orders_order', $orderId, [
|
|
||||||
'order_number' => $orderNumber,
|
|
||||||
'quotation_number' => $quotation['quotation_number'],
|
|
||||||
'project_id' => $projectId,
|
|
||||||
], "Vytvořena objednávka '$orderNumber' z nabídky '{$quotation['quotation_number']}'");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'order_id' => $orderId,
|
|
||||||
'order_number' => $orderNumber,
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'project_number' => $orderNumber,
|
|
||||||
], 'Objednávka byla vytvořena');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_order_number')");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateOrder(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$order) {
|
|
||||||
errorResponse('Objednávka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$newStatus = $input['status'] ?? null;
|
|
||||||
$notes = $input['notes'] ?? null;
|
|
||||||
$newOrderNumber = isset($input['order_number']) ? trim($input['order_number']) : null;
|
|
||||||
|
|
||||||
// Delkove limity
|
|
||||||
if ($notes !== null && mb_strlen($notes) > 5000) {
|
|
||||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
|
||||||
}
|
|
||||||
if ($newOrderNumber !== null && mb_strlen($newOrderNumber) > 50) {
|
|
||||||
errorResponse('Číslo objednávky je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate status transition
|
|
||||||
if ($newStatus && $newStatus !== $order['status']) {
|
|
||||||
$valid = getValidTransitions($order['status']);
|
|
||||||
if (!in_array($newStatus, $valid)) {
|
|
||||||
errorResponse("Neplatný přechod stavu z '{$order['status']}' na '$newStatus'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate order number uniqueness
|
|
||||||
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
|
||||||
if (empty($newOrderNumber)) {
|
|
||||||
errorResponse('Číslo objednávky nesmí být prázdné');
|
|
||||||
}
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ? AND id != ?');
|
|
||||||
$stmt->execute([$newOrderNumber, $id]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('Toto číslo objednávky již existuje');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$updates = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($newOrderNumber !== null && $newOrderNumber !== $order['order_number']) {
|
|
||||||
$updates[] = 'order_number = ?';
|
|
||||||
$params[] = $newOrderNumber;
|
|
||||||
|
|
||||||
// Sync project number
|
|
||||||
$stmt = $pdo->prepare('UPDATE projects SET project_number = ?, modified_at = NOW() WHERE order_id = ?');
|
|
||||||
$stmt->execute([$newOrderNumber, $id]);
|
|
||||||
}
|
|
||||||
if ($newStatus !== null) {
|
|
||||||
$updates[] = 'status = ?';
|
|
||||||
$params[] = $newStatus;
|
|
||||||
}
|
|
||||||
if ($notes !== null) {
|
|
||||||
$updates[] = 'notes = ?';
|
|
||||||
$params[] = $notes;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($updates)) {
|
|
||||||
$updates[] = 'modified_at = NOW()';
|
|
||||||
$params[] = $id;
|
|
||||||
$sql = 'UPDATE orders SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync project status with order status
|
|
||||||
if ($newStatus && $newStatus !== $order['status']) {
|
|
||||||
$projectStatus = null;
|
|
||||||
if ($newStatus === 'stornovana') {
|
|
||||||
$projectStatus = 'zruseny';
|
|
||||||
} elseif ($newStatus === 'dokoncena') {
|
|
||||||
$projectStatus = 'dokonceny';
|
|
||||||
} elseif ($newStatus === 'v_realizaci') {
|
|
||||||
$projectStatus = 'aktivni';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($projectStatus) {
|
|
||||||
$stmt = $pdo->prepare('UPDATE projects SET status = ?, modified_at = NOW() WHERE order_id = ?');
|
|
||||||
$stmt->execute([$projectStatus, $id]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'orders_order',
|
|
||||||
$id,
|
|
||||||
['status' => $order['status'], 'notes' => $order['notes']],
|
|
||||||
['status' => $newStatus ?? $order['status'], 'notes' => $notes ?? $order['notes']],
|
|
||||||
"Upravena objednávka '{$order['order_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Objednávka byla aktualizována');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteOrder(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$order = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$order) {
|
|
||||||
errorResponse('Objednávka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
// Delete project linked to this order
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM projects WHERE order_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// Delete order items and sections
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM order_items WHERE order_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM order_sections WHERE order_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// Delete order
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM orders WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// Remove back-reference from quotation
|
|
||||||
$stmt = $pdo->prepare('UPDATE quotations SET order_id = NULL, modified_at = NOW() WHERE order_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logDelete('orders_order', $id, [
|
|
||||||
'order_number' => $order['order_number'],
|
|
||||||
'quotation_id' => $order['quotation_id'],
|
|
||||||
], "Smazána objednávka '{$order['order_number']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Objednávka byla smazána');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/projects-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -111,423 +112,3 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Number generation ---
|
// --- Number generation ---
|
||||||
|
|
||||||
function generateProjectNumber(PDO $pdo): string
|
|
||||||
{
|
|
||||||
return generateSharedNumber($pdo);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetNextNumber(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$number = generateProjectNumber($pdo);
|
|
||||||
successResponse(['number' => $number]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCreateProject(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$name = trim($input['name'] ?? '');
|
|
||||||
if (!$name) {
|
|
||||||
errorResponse('Název projektu je povinný');
|
|
||||||
}
|
|
||||||
if (mb_strlen($name) > 255) {
|
|
||||||
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$customerId = isset($input['customer_id']) ? (int)$input['customer_id'] : null;
|
|
||||||
if (!$customerId) {
|
|
||||||
errorResponse('Zákazník je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify customer exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM customers WHERE id = ?');
|
|
||||||
$stmt->execute([$customerId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Zákazník nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$startDate = $input['start_date'] ?? date('Y-m-d');
|
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
|
|
||||||
errorResponse('Neplatný formát data zahájení');
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectNumber = trim($input['project_number'] ?? '');
|
|
||||||
if ($projectNumber && mb_strlen($projectNumber) > 50) {
|
|
||||||
errorResponse('Číslo projektu je příliš dlouhé (max 50 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock for concurrent number generation
|
|
||||||
$locked = $pdo->query("SELECT GET_LOCK('boha_project_number', 5)")->fetchColumn();
|
|
||||||
if (!$locked) {
|
|
||||||
errorResponse('Nepodařilo se získat zámek pro číslo projektu, zkuste to znovu', 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
// Generate or validate number
|
|
||||||
if (!$projectNumber) {
|
|
||||||
$projectNumber = generateProjectNumber($pdo);
|
|
||||||
} else {
|
|
||||||
// Validate uniqueness against both tables
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM orders WHERE order_number = ?');
|
|
||||||
$stmt->execute([$projectNumber]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
|
||||||
errorResponse('Číslo projektu je již použito jako číslo objednávky');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE project_number = ?');
|
|
||||||
$stmt->execute([$projectNumber]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
|
||||||
errorResponse('Číslo projektu je již použito');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("
|
|
||||||
INSERT INTO projects (
|
|
||||||
project_number, name, customer_id,
|
|
||||||
status, start_date, created_at, modified_at
|
|
||||||
) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW())
|
|
||||||
");
|
|
||||||
$stmt->execute([
|
|
||||||
$projectNumber,
|
|
||||||
$name,
|
|
||||||
$customerId,
|
|
||||||
$startDate,
|
|
||||||
]);
|
|
||||||
$projectId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
|
||||||
|
|
||||||
AuditLog::logCreate('projects_project', $projectId, [
|
|
||||||
'project_number' => $projectNumber,
|
|
||||||
'name' => $name,
|
|
||||||
'customer_id' => $customerId,
|
|
||||||
], "Ručně vytvořen projekt '$projectNumber'");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'project_id' => $projectId,
|
|
||||||
'project_number' => $projectNumber,
|
|
||||||
], 'Projekt byl vytvořen');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
$pdo->query("SELECT RELEASE_LOCK('boha_project_number')");
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDeleteProject(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$project = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$project) {
|
|
||||||
errorResponse('Projekt nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only manually created projects (without order_id) can be deleted
|
|
||||||
if (!empty($project['order_id'])) {
|
|
||||||
errorResponse('Projekt propojený s objednávkou nelze smazat. Smažte objednávku.', 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
// Delete project notes
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE project_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
// Delete project
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM projects WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'projects_project',
|
|
||||||
$id,
|
|
||||||
['status' => $project['status']],
|
|
||||||
['status' => 'deleted'],
|
|
||||||
"Smazán ruční projekt '{$project['project_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Projekt byl smazán');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$search = trim($_GET['search'] ?? '');
|
|
||||||
$sort = $_GET['sort'] ?? 'created_at';
|
|
||||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
||||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
|
||||||
$perPage = min(500, max(1, (int) ($_GET['per_page'] ?? 500)));
|
|
||||||
|
|
||||||
$sortMap = [
|
|
||||||
'ProjectNumber' => 'p.project_number',
|
|
||||||
'project_number' => 'p.project_number',
|
|
||||||
'Name' => 'p.name',
|
|
||||||
'name' => 'p.name',
|
|
||||||
'Status' => 'p.status',
|
|
||||||
'status' => 'p.status',
|
|
||||||
'StartDate' => 'p.start_date',
|
|
||||||
'start_date' => 'p.start_date',
|
|
||||||
'EndDate' => 'p.end_date',
|
|
||||||
'end_date' => 'p.end_date',
|
|
||||||
'CreatedAt' => 'p.created_at',
|
|
||||||
'created_at' => 'p.created_at',
|
|
||||||
];
|
|
||||||
if (!isset($sortMap[$sort])) {
|
|
||||||
errorResponse('Neplatný parametr řazení', 400);
|
|
||||||
}
|
|
||||||
$sortCol = $sortMap[$sort];
|
|
||||||
|
|
||||||
$where = 'WHERE 1=1';
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
if ($search) {
|
|
||||||
$search = mb_substr($search, 0, 100);
|
|
||||||
$where .= ' AND (p.project_number LIKE ? OR p.name LIKE ? OR c.name LIKE ?)';
|
|
||||||
$searchParam = "%{$search}%";
|
|
||||||
$params = [$searchParam, $searchParam, $searchParam];
|
|
||||||
}
|
|
||||||
|
|
||||||
$countSql = "
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN customers c ON p.customer_id = c.id
|
|
||||||
LEFT JOIN orders o ON p.order_id = o.id
|
|
||||||
$where
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($countSql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$total = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
$offset = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date,
|
|
||||||
p.order_id, p.quotation_id, p.created_at,
|
|
||||||
c.name as customer_name,
|
|
||||||
o.order_number
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN customers c ON p.customer_id = c.id
|
|
||||||
LEFT JOIN orders o ON p.order_id = o.id
|
|
||||||
$where
|
|
||||||
ORDER BY $sortCol $order
|
|
||||||
LIMIT $perPage OFFSET $offset
|
|
||||||
";
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$projects = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'projects' => $projects,
|
|
||||||
'total' => $total,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT p.*,
|
|
||||||
c.name as customer_name,
|
|
||||||
o.order_number, o.status as order_status,
|
|
||||||
q.quotation_number
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN customers c ON p.customer_id = c.id
|
|
||||||
LEFT JOIN orders o ON p.order_id = o.id
|
|
||||||
LEFT JOIN quotations q ON p.quotation_id = q.id
|
|
||||||
WHERE p.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$project = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$project) {
|
|
||||||
errorResponse('Projekt nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse($project);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUpdateProject(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM projects WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$project = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$project) {
|
|
||||||
errorResponse('Projekt nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
// Validace statusu
|
|
||||||
if (isset($input['status'])) {
|
|
||||||
$validStatuses = ['aktivni', 'dokonceny', 'zruseny'];
|
|
||||||
if (!in_array($input['status'], $validStatuses)) {
|
|
||||||
errorResponse('Neplatný stav projektu');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validace dat
|
|
||||||
if (
|
|
||||||
isset($input['start_date'])
|
|
||||||
&& $input['start_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
|
||||||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['start_date'])
|
|
||||||
) {
|
|
||||||
errorResponse('Neplatný formát data zahájení');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
isset($input['end_date'])
|
|
||||||
&& $input['end_date'] !== null // @phpstan-ignore notIdentical.alwaysTrue
|
|
||||||
&& $input['end_date'] !== ''
|
|
||||||
&& !preg_match('/^\d{4}-\d{2}-\d{2}$/', $input['end_date'])
|
|
||||||
) {
|
|
||||||
errorResponse('Neplatný formát data ukončení');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delkove limity
|
|
||||||
$name = $input['name'] ?? $project['name'];
|
|
||||||
if (mb_strlen($name) > 255) {
|
|
||||||
errorResponse('Název projektu je příliš dlouhý (max 255 znaků)');
|
|
||||||
}
|
|
||||||
$notes = $input['notes'] ?? $project['notes'];
|
|
||||||
if ($notes !== null && mb_strlen($notes) > 5000) {
|
|
||||||
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE projects SET
|
|
||||||
name = ?,
|
|
||||||
status = ?,
|
|
||||||
start_date = ?,
|
|
||||||
end_date = ?,
|
|
||||||
notes = ?,
|
|
||||||
modified_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$name,
|
|
||||||
$input['status'] ?? $project['status'],
|
|
||||||
$input['start_date'] ?? $project['start_date'],
|
|
||||||
$input['end_date'] ?? $project['end_date'],
|
|
||||||
$notes,
|
|
||||||
$id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'projects_project',
|
|
||||||
$id,
|
|
||||||
['name' => $project['name'], 'status' => $project['status']],
|
|
||||||
['name' => $input['name'] ?? $project['name'], 'status' => $input['status'] ?? $project['status']],
|
|
||||||
"Upraven projekt '{$project['project_number']}'"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Projekt byl aktualizován');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGetNotes(PDO $pdo, int $projectId): void
|
|
||||||
{
|
|
||||||
// Verify project exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
|
||||||
$stmt->execute([$projectId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Projekt nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT id, project_id, user_id, user_name, content, created_at
|
|
||||||
FROM project_notes
|
|
||||||
WHERE project_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
');
|
|
||||||
$stmt->execute([$projectId]);
|
|
||||||
$notes = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse(['notes' => $notes]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $authData */
|
|
||||||
function handleAddNote(PDO $pdo, int $projectId, array $authData): void
|
|
||||||
{
|
|
||||||
// Verify project exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM projects WHERE id = ?');
|
|
||||||
$stmt->execute([$projectId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Projekt nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$content = trim($input['content'] ?? '');
|
|
||||||
|
|
||||||
if (!$content) {
|
|
||||||
errorResponse('Text poznámky je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mb_strlen($content) > 5000) {
|
|
||||||
errorResponse('Poznámka je příliš dlouhá (max 5000 znaků)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$userName = $authData['user']['full_name'] ?? $authData['user']['username'] ?? 'Neznámý';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO project_notes (project_id, user_id, user_name, content, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, NOW())
|
|
||||||
');
|
|
||||||
$stmt->execute([$projectId, $authData['user_id'], $userName, $content]);
|
|
||||||
|
|
||||||
$noteId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Fetch the new note
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'SELECT id, project_id, user_id, user_name, content, created_at FROM project_notes WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$noteId]);
|
|
||||||
$note = $stmt->fetch();
|
|
||||||
|
|
||||||
successResponse(['note' => $note], 'Poznámka byla přidána');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $authData */
|
|
||||||
function handleDeleteNote(PDO $pdo, int $noteId, array $authData): void
|
|
||||||
{
|
|
||||||
// Only admins can delete notes
|
|
||||||
$isAdmin = $authData['user']['is_admin'] ?? false;
|
|
||||||
if (!$isAdmin) {
|
|
||||||
errorResponse('Pouze administrátoři mohou mazat poznámky', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT id, project_id, content FROM project_notes WHERE id = ?');
|
|
||||||
$stmt->execute([$noteId]);
|
|
||||||
$note = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$note) {
|
|
||||||
errorResponse('Poznámka nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM project_notes WHERE id = ?');
|
|
||||||
$stmt->execute([$noteId]);
|
|
||||||
|
|
||||||
successResponse(null, 'Poznámka byla smazána');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ require_once dirname(__DIR__) . '/config.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
require_once dirname(__DIR__) . '/includes/CnbRates.php';
|
||||||
|
require_once __DIR__ . '/handlers/received-invoices-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -94,504 +95,3 @@ try {
|
|||||||
// --- Allowed MIME types ---
|
// --- Allowed MIME types ---
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
function getAllowedMimes(): array
|
|
||||||
{
|
|
||||||
return ['application/pdf', 'image/jpeg', 'image/png'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Stats ---
|
|
||||||
|
|
||||||
function handleGetStats(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
|
||||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
|
||||||
|
|
||||||
$monthStart = sprintf('%04d-%02d-01', $year, $month);
|
|
||||||
$monthEnd = date('Y-m-t', strtotime($monthStart));
|
|
||||||
|
|
||||||
// Celkem v měsíci (issue_date)
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT currency, SUM(amount) as total, SUM(vat_amount) as vat_total, COUNT(*) as cnt
|
|
||||||
FROM received_invoices
|
|
||||||
WHERE issue_date BETWEEN ? AND ?
|
|
||||||
GROUP BY currency
|
|
||||||
');
|
|
||||||
$stmt->execute([$monthStart, $monthEnd]);
|
|
||||||
$monthRows = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$totalAmounts = [];
|
|
||||||
$vatAmounts = [];
|
|
||||||
$czkItems = [];
|
|
||||||
$vatCzkItems = [];
|
|
||||||
$monthCount = 0;
|
|
||||||
|
|
||||||
foreach ($monthRows as $r) {
|
|
||||||
$totalAmounts[$r['currency']] = round((float) $r['total'], 2);
|
|
||||||
$vatAmounts[$r['currency']] = round((float) $r['vat_total'], 2);
|
|
||||||
$monthCount += (int) $r['cnt'];
|
|
||||||
$czkItems[] = [
|
|
||||||
'amount' => round((float) $r['total'], 2),
|
|
||||||
'currency' => $r['currency'],
|
|
||||||
'date' => $monthStart,
|
|
||||||
];
|
|
||||||
$vatCzkItems[] = [
|
|
||||||
'amount' => round((float) $r['vat_total'], 2),
|
|
||||||
'currency' => $r['currency'],
|
|
||||||
'date' => $monthStart,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalArr = [];
|
|
||||||
foreach ($totalAmounts as $cur => $amt) {
|
|
||||||
$totalArr[] = ['amount' => $amt, 'currency' => $cur];
|
|
||||||
}
|
|
||||||
$vatArr = [];
|
|
||||||
foreach ($vatAmounts as $cur => $amt) {
|
|
||||||
$vatArr[] = ['amount' => $amt, 'currency' => $cur];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Neuhrazeno celkově
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT currency, SUM(amount) as total, COUNT(*) as cnt
|
|
||||||
FROM received_invoices WHERE status = ?
|
|
||||||
GROUP BY currency
|
|
||||||
');
|
|
||||||
$stmt->execute(['unpaid']);
|
|
||||||
$unpaidRows = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$unpaidAmounts = [];
|
|
||||||
$unpaidCzkItems = [];
|
|
||||||
$unpaidCount = 0;
|
|
||||||
foreach ($unpaidRows as $r) {
|
|
||||||
$unpaidAmounts[] = ['amount' => round((float) $r['total'], 2), 'currency' => $r['currency']];
|
|
||||||
$unpaidCount += (int) $r['cnt'];
|
|
||||||
$unpaidCzkItems[] = [
|
|
||||||
'amount' => round((float) $r['total'], 2),
|
|
||||||
'currency' => $r['currency'],
|
|
||||||
'date' => date('Y-m-d'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cnb = CnbRates::getInstance();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'total_month' => $totalArr,
|
|
||||||
'total_month_czk' => $cnb->sumToCzk($czkItems),
|
|
||||||
'vat_month' => $vatArr,
|
|
||||||
'vat_month_czk' => $cnb->sumToCzk($vatCzkItems),
|
|
||||||
'unpaid' => $unpaidAmounts,
|
|
||||||
'unpaid_czk' => $cnb->sumToCzk($unpaidCzkItems),
|
|
||||||
'unpaid_count' => $unpaidCount,
|
|
||||||
'month_count' => $monthCount,
|
|
||||||
'month' => $month,
|
|
||||||
'year' => $year,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- List ---
|
|
||||||
|
|
||||||
function handleGetList(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$month = max(1, min(12, (int) ($_GET['month'] ?? (int) date('n'))));
|
|
||||||
$year = max(2020, min(2099, (int) ($_GET['year'] ?? (int) date('Y'))));
|
|
||||||
$search = trim($_GET['search'] ?? '');
|
|
||||||
$sort = $_GET['sort'] ?? 'created_at';
|
|
||||||
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
|
|
||||||
|
|
||||||
$sortMap = [
|
|
||||||
'supplier_name' => 'supplier_name',
|
|
||||||
'invoice_number' => 'invoice_number',
|
|
||||||
'status' => 'status',
|
|
||||||
'issue_date' => 'issue_date',
|
|
||||||
'due_date' => 'due_date',
|
|
||||||
'amount' => 'amount',
|
|
||||||
'created_at' => 'created_at',
|
|
||||||
];
|
|
||||||
if (!isset($sortMap[$sort])) {
|
|
||||||
errorResponse('Neplatný parametr řazení', 400);
|
|
||||||
}
|
|
||||||
$sortCol = $sortMap[$sort];
|
|
||||||
|
|
||||||
$where = 'WHERE month = ? AND year = ?';
|
|
||||||
$params = [$month, $year];
|
|
||||||
|
|
||||||
if ($search) {
|
|
||||||
$search = mb_substr($search, 0, 100);
|
|
||||||
$where .= ' AND (supplier_name LIKE ? OR invoice_number LIKE ?)';
|
|
||||||
$searchParam = "%{$search}%";
|
|
||||||
$params[] = $searchParam;
|
|
||||||
$params[] = $searchParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT id, supplier_name, invoice_number, description,
|
|
||||||
amount, currency, vat_rate, vat_amount,
|
|
||||||
issue_date, due_date, paid_date, status,
|
|
||||||
file_name, file_mime, file_size, notes,
|
|
||||||
created_at, modified_at
|
|
||||||
FROM received_invoices
|
|
||||||
$where
|
|
||||||
ORDER BY $sortCol $order
|
|
||||||
";
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$invoices = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse(['invoices' => $invoices]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Detail ---
|
|
||||||
|
|
||||||
function handleGetDetail(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT id, supplier_name, invoice_number, description,
|
|
||||||
amount, currency, vat_rate, vat_amount,
|
|
||||||
issue_date, due_date, paid_date, status,
|
|
||||||
file_name, file_mime, file_size, notes,
|
|
||||||
uploaded_by, created_at, modified_at
|
|
||||||
FROM received_invoices WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse($invoice);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- File streaming ---
|
|
||||||
|
|
||||||
function handleGetFile(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT file_data, file_name, file_mime, file_size FROM received_invoices WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$row = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$row || !$row['file_data']) {
|
|
||||||
errorResponse('Soubor nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$safeFilename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($row['file_name']));
|
|
||||||
header('Content-Type: ' . $row['file_mime']);
|
|
||||||
header('Content-Disposition: inline; filename="' . $safeFilename . '"');
|
|
||||||
header('Content-Length: ' . $row['file_size']);
|
|
||||||
header_remove('X-Content-Type-Options');
|
|
||||||
echo $row['file_data'];
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bulk upload ---
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $authData */
|
|
||||||
function handleBulkUpload(PDO $pdo, array $authData): void
|
|
||||||
{
|
|
||||||
$invoicesJson = $_POST['invoices'] ?? '[]';
|
|
||||||
$invoicesMeta = json_decode($invoicesJson, true);
|
|
||||||
|
|
||||||
if (!is_array($invoicesMeta)) {
|
|
||||||
errorResponse('Neplatná metadata');
|
|
||||||
}
|
|
||||||
if (count($invoicesMeta) === 0) {
|
|
||||||
errorResponse('Žádné faktury k nahrání');
|
|
||||||
}
|
|
||||||
if (count($invoicesMeta) > 20) {
|
|
||||||
errorResponse('Maximálně 20 faktur najednou');
|
|
||||||
}
|
|
||||||
|
|
||||||
$files = $_FILES['files'] ?? [];
|
|
||||||
$fileCount = is_array($files['tmp_name'] ?? null) ? count($files['tmp_name']) : 0;
|
|
||||||
|
|
||||||
if ($fileCount !== count($invoicesMeta)) {
|
|
||||||
errorResponse('Počet souborů neodpovídá počtu metadat');
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowedMimes = getAllowedMimes();
|
|
||||||
$validCurrencies = ['CZK', 'EUR', 'USD', 'GBP'];
|
|
||||||
$validVatRates = [0, 10, 12, 15, 21];
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
try {
|
|
||||||
$created = [];
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO received_invoices (
|
|
||||||
month, year, supplier_name, invoice_number, description,
|
|
||||||
amount, currency, vat_rate, vat_amount,
|
|
||||||
issue_date, due_date, status,
|
|
||||||
file_data, file_name, file_mime, file_size,
|
|
||||||
notes, uploaded_by
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
|
|
||||||
for ($i = 0; $i < $fileCount; $i++) {
|
|
||||||
$meta = $invoicesMeta[$i];
|
|
||||||
$tmpName = $files['tmp_name'][$i];
|
|
||||||
$fileError = $files['error'][$i];
|
|
||||||
$fileSize = $files['size'][$i];
|
|
||||||
$fileName = $files['name'][$i];
|
|
||||||
|
|
||||||
if ($fileError !== UPLOAD_ERR_OK) {
|
|
||||||
errorResponse("Chyba při nahrávání souboru #" . ($i + 1));
|
|
||||||
}
|
|
||||||
if ($fileSize > 10 * 1024 * 1024) {
|
|
||||||
errorResponse("Soubor #" . ($i + 1) . " je větší než 10 MB");
|
|
||||||
}
|
|
||||||
|
|
||||||
$finfo = new finfo(FILEINFO_MIME_TYPE);
|
|
||||||
$mime = $finfo->file($tmpName);
|
|
||||||
if (!in_array($mime, $allowedMimes)) {
|
|
||||||
errorResponse("Soubor #" . ($i + 1) . ": nepodporovaný formát (povoleno: PDF, JPEG, PNG)");
|
|
||||||
}
|
|
||||||
|
|
||||||
$supplierName = trim($meta['supplier_name'] ?? '');
|
|
||||||
if ($supplierName === '') {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": dodavatel je povinný");
|
|
||||||
}
|
|
||||||
if (mb_strlen($supplierName) > 255) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": název dodavatele je příliš dlouhý");
|
|
||||||
}
|
|
||||||
|
|
||||||
$amount = (float) ($meta['amount'] ?? 0);
|
|
||||||
if ($amount <= 0) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": částka musí být větší než 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
$currency = trim($meta['currency'] ?? 'CZK');
|
|
||||||
if (!in_array($currency, $validCurrencies)) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": neplatná měna");
|
|
||||||
}
|
|
||||||
|
|
||||||
$vatRate = (float) ($meta['vat_rate'] ?? 21);
|
|
||||||
if (!in_array((int) $vatRate, $validVatRates)) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": neplatná sazba DPH");
|
|
||||||
}
|
|
||||||
|
|
||||||
$vatAmount = round($amount * $vatRate / 100, 2);
|
|
||||||
$invoiceNumber = trim($meta['invoice_number'] ?? '');
|
|
||||||
$description = trim($meta['description'] ?? '');
|
|
||||||
$issueDate = trim($meta['issue_date'] ?? '');
|
|
||||||
$dueDate = trim($meta['due_date'] ?? '');
|
|
||||||
$notes = trim($meta['notes'] ?? '');
|
|
||||||
|
|
||||||
// Validace dat
|
|
||||||
if ($issueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $issueDate) || !strtotime($issueDate))) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data vystavení");
|
|
||||||
}
|
|
||||||
if ($dueDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dueDate) || !strtotime($dueDate))) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": neplatný formát data splatnosti");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Délkové limity
|
|
||||||
if (mb_strlen($invoiceNumber) > 100) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": číslo faktury je příliš dlouhé");
|
|
||||||
}
|
|
||||||
if (mb_strlen($description) > 500) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": popis je příliš dlouhý");
|
|
||||||
}
|
|
||||||
if (mb_strlen($notes) > 5000) {
|
|
||||||
errorResponse("Faktura #" . ($i + 1) . ": poznámka je příliš dlouhá");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Určit month/year z issue_date nebo aktuální
|
|
||||||
if ($issueDate) {
|
|
||||||
$dt = new DateTime($issueDate);
|
|
||||||
$month = (int) $dt->format('n');
|
|
||||||
$year = (int) $dt->format('Y');
|
|
||||||
} else {
|
|
||||||
$month = (int) date('n');
|
|
||||||
$year = (int) date('Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileData = file_get_contents($tmpName);
|
|
||||||
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
|
|
||||||
|
|
||||||
$stmt->execute([
|
|
||||||
$month,
|
|
||||||
$year,
|
|
||||||
$supplierName,
|
|
||||||
$invoiceNumber ?: null,
|
|
||||||
$description ?: null,
|
|
||||||
$amount,
|
|
||||||
$currency,
|
|
||||||
$vatRate,
|
|
||||||
$vatAmount,
|
|
||||||
$issueDate ?: null,
|
|
||||||
$dueDate ?: null,
|
|
||||||
'unpaid',
|
|
||||||
$fileData,
|
|
||||||
$safeName,
|
|
||||||
$mime,
|
|
||||||
$fileSize,
|
|
||||||
$notes ?: null,
|
|
||||||
$authData['user_id'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$created[] = (int) $pdo->lastInsertId();
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logCreate('received_invoices', $created[0], [
|
|
||||||
'count' => count($created),
|
|
||||||
'ids' => $created,
|
|
||||||
], 'Nahráno ' . count($created) . ' přijatých faktur');
|
|
||||||
|
|
||||||
successResponse(['ids' => $created], 'Faktury byly nahrány');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update ---
|
|
||||||
|
|
||||||
function handleUpdateReceivedInvoice(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM received_invoices WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$updates = [];
|
|
||||||
$params = [];
|
|
||||||
|
|
||||||
$stringFields = [
|
|
||||||
'supplier_name' => 255,
|
|
||||||
'invoice_number' => 100,
|
|
||||||
'description' => 500,
|
|
||||||
'notes' => 5000,
|
|
||||||
];
|
|
||||||
foreach ($stringFields as $field => $maxLen) {
|
|
||||||
if (array_key_exists($field, $input)) {
|
|
||||||
$val = trim((string) $input[$field]);
|
|
||||||
if ($field === 'supplier_name' && $val === '') {
|
|
||||||
errorResponse('Dodavatel je povinný');
|
|
||||||
}
|
|
||||||
if (mb_strlen($val) > $maxLen) {
|
|
||||||
errorResponse("Pole $field je příliš dlouhé (max $maxLen znaků)");
|
|
||||||
}
|
|
||||||
$updates[] = "$field = ?";
|
|
||||||
$params[] = $val ?: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('amount', $input)) {
|
|
||||||
$amount = (float) $input['amount'];
|
|
||||||
if ($amount <= 0) {
|
|
||||||
errorResponse('Částka musí být větší než 0');
|
|
||||||
}
|
|
||||||
$updates[] = 'amount = ?';
|
|
||||||
$params[] = $amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('currency', $input)) {
|
|
||||||
if (!in_array($input['currency'], ['CZK', 'EUR', 'USD', 'GBP'])) {
|
|
||||||
errorResponse('Neplatná měna');
|
|
||||||
}
|
|
||||||
$updates[] = 'currency = ?';
|
|
||||||
$params[] = $input['currency'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (array_key_exists('vat_rate', $input)) {
|
|
||||||
$vatRate = (float) $input['vat_rate'];
|
|
||||||
if (!in_array((int) $vatRate, [0, 10, 12, 15, 21])) {
|
|
||||||
errorResponse('Neplatná sazba DPH');
|
|
||||||
}
|
|
||||||
$updates[] = 'vat_rate = ?';
|
|
||||||
$params[] = $vatRate;
|
|
||||||
|
|
||||||
$amount = (float) ($input['amount'] ?? $invoice['amount']);
|
|
||||||
$updates[] = 'vat_amount = ?';
|
|
||||||
$params[] = round($amount * $vatRate / 100, 2);
|
|
||||||
} elseif (array_key_exists('amount', $input)) {
|
|
||||||
$vatRate = (float) ($input['vat_rate'] ?? $invoice['vat_rate']);
|
|
||||||
$updates[] = 'vat_amount = ?';
|
|
||||||
$params[] = round((float) $input['amount'] * $vatRate / 100, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (['issue_date', 'due_date'] as $dateField) {
|
|
||||||
if (array_key_exists($dateField, $input)) {
|
|
||||||
$val = trim((string) $input[$dateField]);
|
|
||||||
if ($val && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $val) || !strtotime($val))) {
|
|
||||||
errorResponse("Neplatný formát data: $dateField");
|
|
||||||
}
|
|
||||||
$updates[] = "$dateField = ?";
|
|
||||||
$params[] = $val ?: null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aktualizace month/year pokud se změní issue_date
|
|
||||||
if (array_key_exists('issue_date', $input) && $input['issue_date']) {
|
|
||||||
$dt = new DateTime($input['issue_date']);
|
|
||||||
$updates[] = 'month = ?';
|
|
||||||
$params[] = (int) $dt->format('n');
|
|
||||||
$updates[] = 'year = ?';
|
|
||||||
$params[] = (int) $dt->format('Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Změna stavu - pouze unpaid -> paid (jednosmerny prechod)
|
|
||||||
if (array_key_exists('status', $input)) {
|
|
||||||
$newStatus = $input['status'];
|
|
||||||
if (!in_array($newStatus, ['unpaid', 'paid'])) {
|
|
||||||
errorResponse('Neplatný stav');
|
|
||||||
}
|
|
||||||
if ($invoice['status'] === 'paid' && $newStatus !== 'paid') {
|
|
||||||
errorResponse('Uhrazenou fakturu nelze vrátit do stavu neuhrazená');
|
|
||||||
}
|
|
||||||
if ($newStatus !== $invoice['status']) {
|
|
||||||
$updates[] = 'status = ?';
|
|
||||||
$params[] = $newStatus;
|
|
||||||
if ($newStatus === 'paid') {
|
|
||||||
$updates[] = 'paid_date = CURDATE()';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($updates)) {
|
|
||||||
errorResponse('Žádné změny k uložení');
|
|
||||||
}
|
|
||||||
|
|
||||||
$updates[] = 'modified_at = NOW()';
|
|
||||||
$params[] = $id;
|
|
||||||
$sql = 'UPDATE received_invoices SET ' . implode(', ', $updates) . ' WHERE id = ?';
|
|
||||||
$pdo->prepare($sql)->execute($params);
|
|
||||||
|
|
||||||
AuditLog::logUpdate(
|
|
||||||
'received_invoices',
|
|
||||||
$id,
|
|
||||||
['status' => $invoice['status']],
|
|
||||||
['status' => $input['status'] ?? $invoice['status']],
|
|
||||||
"Aktualizována přijatá faktura #{$id}"
|
|
||||||
);
|
|
||||||
|
|
||||||
successResponse(null, 'Faktura byla aktualizována');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Delete ---
|
|
||||||
|
|
||||||
function handleDeleteReceivedInvoice(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT id, supplier_name, invoice_number FROM received_invoices WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$invoice = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$invoice) {
|
|
||||||
errorResponse('Přijatá faktura nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->prepare('DELETE FROM received_invoices WHERE id = ?')->execute([$id]);
|
|
||||||
|
|
||||||
AuditLog::logDelete('received_invoices', $id, [
|
|
||||||
'supplier_name' => $invoice['supplier_name'],
|
|
||||||
'invoice_number' => $invoice['invoice_number'],
|
|
||||||
], "Smazána přijatá faktura #{$id}");
|
|
||||||
|
|
||||||
successResponse(null, 'Faktura byla smazána');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/roles-handlers.php';
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
@@ -64,237 +65,3 @@ try {
|
|||||||
error_log('Roles API error: ' . $e->getMessage());
|
error_log('Roles API error: ' . $e->getMessage());
|
||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - List all roles with their permissions + all available permissions
|
|
||||||
*/
|
|
||||||
function handleGetRole(PDO $pdo): void
|
|
||||||
{
|
|
||||||
// Get all roles with user count (LEFT JOIN instead of correlated subquery)
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT r.*, COUNT(u.id) as user_count
|
|
||||||
FROM roles r
|
|
||||||
LEFT JOIN users u ON u.role_id = r.id
|
|
||||||
GROUP BY r.id
|
|
||||||
ORDER BY r.id
|
|
||||||
');
|
|
||||||
$roles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Batch fetch all role-permission mappings in one query (was N+1)
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT rp.role_id, p.name
|
|
||||||
FROM role_permissions rp
|
|
||||||
JOIN permissions p ON p.id = rp.permission_id
|
|
||||||
');
|
|
||||||
$allRolePerms = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Group permissions by role_id
|
|
||||||
$permsByRole = [];
|
|
||||||
foreach ($allRolePerms as $rp) {
|
|
||||||
$permsByRole[$rp['role_id']][] = $rp['name'];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($roles as &$role) {
|
|
||||||
$role['permissions'] = $permsByRole[$role['id']] ?? [];
|
|
||||||
$role['permission_count'] = count($role['permissions']);
|
|
||||||
}
|
|
||||||
unset($role);
|
|
||||||
|
|
||||||
// Get all available permissions grouped by module
|
|
||||||
$stmt = $pdo->query('SELECT id, name, display_name, description FROM permissions ORDER BY id');
|
|
||||||
$allPermissions = $stmt->fetchAll();
|
|
||||||
|
|
||||||
$grouped = [];
|
|
||||||
foreach ($allPermissions as $perm) {
|
|
||||||
$parts = explode('.', $perm['name'], 2);
|
|
||||||
$module = $parts[0];
|
|
||||||
if (!isset($grouped[$module])) {
|
|
||||||
$grouped[$module] = [];
|
|
||||||
}
|
|
||||||
$grouped[$module][] = $perm;
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'roles' => $roles,
|
|
||||||
'permissions' => $allPermissions,
|
|
||||||
'permission_groups' => $grouped,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Create new role
|
|
||||||
*/
|
|
||||||
function handleCreateRole(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$name = trim($input['name'] ?? '');
|
|
||||||
$displayName = trim($input['display_name'] ?? '');
|
|
||||||
$description = trim($input['description'] ?? '');
|
|
||||||
$permissions = $input['permissions'] ?? [];
|
|
||||||
|
|
||||||
if (!$name) {
|
|
||||||
errorResponse('Název role je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$displayName) {
|
|
||||||
errorResponse('Zobrazovaný název je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate name format (slug)
|
|
||||||
if (!preg_match('/^[a-z0-9_-]+$/', $name)) {
|
|
||||||
errorResponse('Název role může obsahovat pouze malá písmena, čísla, pomlčky a podtržítka');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check uniqueness
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM roles WHERE name = ?');
|
|
||||||
$stmt->execute([$name]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('Role s tímto názvem již existuje');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create role
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO roles (name, display_name, description)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$name, $displayName, $description ?: null]);
|
|
||||||
$newRoleId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Assign permissions
|
|
||||||
if (!empty($permissions)) {
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
|
||||||
SELECT ?, id FROM permissions WHERE name = ?
|
|
||||||
');
|
|
||||||
foreach ($permissions as $permName) {
|
|
||||||
$stmt->execute([$newRoleId, $permName]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logCreate('role', $newRoleId, [
|
|
||||||
'name' => $name,
|
|
||||||
'display_name' => $displayName,
|
|
||||||
'permissions' => $permissions,
|
|
||||||
], "Vytvořena role '$displayName'");
|
|
||||||
|
|
||||||
successResponse(['id' => $newRoleId], 'Role byla vytvořena');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT - Update role
|
|
||||||
*/
|
|
||||||
function handleUpdateRole(PDO $pdo, int $roleId): void
|
|
||||||
{
|
|
||||||
// Get existing role
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
$role = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$role) {
|
|
||||||
errorResponse('Role nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block editing admin role name
|
|
||||||
if ($role['name'] === 'admin') {
|
|
||||||
errorResponse('Roli administrátora nelze upravovat');
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$displayName = trim($input['display_name'] ?? $role['display_name']);
|
|
||||||
$description = trim($input['description'] ?? $role['description'] ?? '');
|
|
||||||
$permissions = $input['permissions'] ?? null;
|
|
||||||
|
|
||||||
if (!$displayName) {
|
|
||||||
errorResponse('Zobrazovaný název je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->beginTransaction();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update role
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE roles SET display_name = ?, description = ?
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$displayName, $description ?: null, $roleId]);
|
|
||||||
|
|
||||||
// Update permissions if provided
|
|
||||||
if ($permissions !== null) {
|
|
||||||
// Remove existing permissions
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM role_permissions WHERE role_id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
|
|
||||||
// Add new permissions
|
|
||||||
if (!empty($permissions)) {
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
|
||||||
SELECT ?, id FROM permissions WHERE name = ?
|
|
||||||
');
|
|
||||||
foreach ($permissions as $permName) {
|
|
||||||
$stmt->execute([$roleId, $permName]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$pdo->commit();
|
|
||||||
|
|
||||||
AuditLog::logUpdate('role', $roleId, [
|
|
||||||
'display_name' => $role['display_name'],
|
|
||||||
], [
|
|
||||||
'display_name' => $displayName,
|
|
||||||
'permissions' => $permissions,
|
|
||||||
], "Upravena role '$displayName'");
|
|
||||||
|
|
||||||
successResponse(null, 'Role byla aktualizována');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
$pdo->rollBack();
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete role
|
|
||||||
*/
|
|
||||||
function handleDeleteRole(PDO $pdo, int $roleId): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
$role = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$role) {
|
|
||||||
errorResponse('Role nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block deleting admin role
|
|
||||||
if ($role['name'] === 'admin') {
|
|
||||||
errorResponse('Roli administrátora nelze smazat');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if role has users
|
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role_id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
$userCount = $stmt->fetchColumn();
|
|
||||||
|
|
||||||
if ($userCount > 0) {
|
|
||||||
errorResponse("Nelze smazat roli s {$userCount} přiřazenými uživateli. Nejprve změňte roli těmto uživatelům.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete role (cascade deletes role_permissions)
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
|
|
||||||
AuditLog::logDelete('role', $roleId, $role, "Smazána role '{$role['display_name']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Role byla smazána');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
|
require_once __DIR__ . '/handlers/session-handlers.php';
|
||||||
|
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
setSecurityHeaders();
|
setSecurityHeaders();
|
||||||
@@ -48,24 +49,6 @@ if (!in_array($_SERVER['REQUEST_METHOD'], ['GET', 'POST'])) {
|
|||||||
errorResponse('Metoda není povolena', 405);
|
errorResponse('Metoda není povolena', 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
function get2FAInfo(PDO $pdo, int $userId): array
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$stmt = $pdo->prepare("SELECT totp_enabled FROM users WHERE id = ?");
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$row = $stmt->fetch();
|
|
||||||
|
|
||||||
$r2fa = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
|
||||||
return [
|
|
||||||
'totp_enabled' => (bool) ($row['totp_enabled'] ?? false),
|
|
||||||
'require_2fa' => (bool) $r2fa->fetchColumn(),
|
|
||||||
];
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
return ['totp_enabled' => false, 'require_2fa' => false];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$authData = JWTAuth::optionalAuth();
|
$authData = JWTAuth::optionalAuth();
|
||||||
|
|
||||||
if ($authData) {
|
if ($authData) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
|
require_once __DIR__ . '/handlers/sessions-handlers.php';
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
@@ -64,180 +65,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - List all active sessions for current user
|
|
||||||
*/
|
|
||||||
function handleGetSession(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
|
||||||
{
|
|
||||||
// Cleanup: expirované + rotované tokeny po grace period
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND (expires_at < NOW()'
|
|
||||||
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
|
||||||
. JWTAuth::getGracePeriod() . ' SECOND)))'
|
|
||||||
);
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
|
||||||
// Jen aktivní sessions (nereplacované)
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
ip_address,
|
|
||||||
user_agent,
|
|
||||||
created_at,
|
|
||||||
expires_at,
|
|
||||||
token_hash
|
|
||||||
FROM refresh_tokens
|
|
||||||
WHERE user_id = ? AND replaced_at IS NULL
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$sessions = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Process sessions to add is_current flag and parse user agent
|
|
||||||
$processedSessions = array_map(function ($session) use ($currentTokenHash) {
|
|
||||||
return [
|
|
||||||
'id' => (int) $session['id'],
|
|
||||||
'ip_address' => $session['ip_address'],
|
|
||||||
'user_agent' => $session['user_agent'],
|
|
||||||
'device_info' => parseUserAgent($session['user_agent']),
|
|
||||||
'created_at' => $session['created_at'],
|
|
||||||
'expires_at' => $session['expires_at'],
|
|
||||||
'is_current' => $currentTokenHash && $session['token_hash'] === $currentTokenHash,
|
|
||||||
];
|
|
||||||
}, $sessions);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'sessions' => $processedSessions,
|
|
||||||
'total' => count($processedSessions),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete a specific session
|
|
||||||
*/
|
|
||||||
function handleDeleteSession(PDO $pdo, int $sessionId, int $userId, ?string $currentTokenHash): void
|
|
||||||
{
|
|
||||||
// Verify the session belongs to the current user
|
|
||||||
$stmt = $pdo->prepare('SELECT token_hash FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
|
||||||
$stmt->execute([$sessionId, $userId]);
|
|
||||||
$session = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$session) {
|
|
||||||
errorResponse('Relace nebyla nalezena', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if trying to delete current session
|
|
||||||
if ($currentTokenHash && $session['token_hash'] === $currentTokenHash) {
|
|
||||||
// Check if force parameter is set
|
|
||||||
$input = getJsonInput();
|
|
||||||
if (!($input['force'] ?? false)) {
|
|
||||||
errorResponse('Nelze smazat aktuální relaci. Použijte tlačítko odhlášení.', 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the session
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE id = ? AND user_id = ?');
|
|
||||||
$stmt->execute([$sessionId, $userId]);
|
|
||||||
|
|
||||||
successResponse(null, 'Relace byla úspěšně ukončena');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete all sessions except current
|
|
||||||
*/
|
|
||||||
function handleDeleteAllSessions(PDO $pdo, int $userId, ?string $currentTokenHash): void
|
|
||||||
{
|
|
||||||
if (!$currentTokenHash) {
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$deleted = $stmt->rowCount();
|
|
||||||
} else {
|
|
||||||
// Ponechat aktuální session, smazat ostatní (včetně replaced)
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND token_hash != ?');
|
|
||||||
$stmt->execute([$userId, $currentTokenHash]);
|
|
||||||
$deleted = $stmt->rowCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'deleted' => $deleted,
|
|
||||||
], $deleted > 0 ? 'Ostatní relace byly úspěšně ukončeny' : 'Žádné další relace k ukončení');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse user agent string to extract device/browser info
|
|
||||||
*
|
|
||||||
* @return array{browser: string, os: string}
|
|
||||||
*/
|
|
||||||
function parseUserAgent(?string $userAgent): array
|
|
||||||
{
|
|
||||||
if (empty($userAgent)) {
|
|
||||||
return [
|
|
||||||
'browser' => 'Neznámý prohlížeč',
|
|
||||||
'os' => 'Neznámý systém',
|
|
||||||
'device' => 'Neznámé zařízení',
|
|
||||||
'icon' => 'device',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$browser = 'Neznámý prohlížeč';
|
|
||||||
$os = 'Neznámý systém';
|
|
||||||
$device = 'desktop';
|
|
||||||
$icon = 'desktop';
|
|
||||||
|
|
||||||
// Detect browser
|
|
||||||
if (preg_match('/Edg(e|A|iOS)?\/[\d.]+/i', $userAgent)) {
|
|
||||||
$browser = 'Microsoft Edge';
|
|
||||||
} elseif (preg_match('/OPR\/[\d.]+|Opera/i', $userAgent)) {
|
|
||||||
$browser = 'Opera';
|
|
||||||
} elseif (preg_match('/Chrome\/[\d.]+/i', $userAgent) && !preg_match('/Chromium/i', $userAgent)) {
|
|
||||||
$browser = 'Google Chrome';
|
|
||||||
} elseif (preg_match('/Firefox\/[\d.]+/i', $userAgent)) {
|
|
||||||
$browser = 'Mozilla Firefox';
|
|
||||||
} elseif (preg_match('/Safari\/[\d.]+/i', $userAgent) && !preg_match('/Chrome/i', $userAgent)) {
|
|
||||||
$browser = 'Safari';
|
|
||||||
} elseif (preg_match('/MSIE|Trident/i', $userAgent)) {
|
|
||||||
$browser = 'Internet Explorer';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect OS
|
|
||||||
if (preg_match('/Windows NT 10/i', $userAgent)) {
|
|
||||||
$os = 'Windows 10/11';
|
|
||||||
} elseif (preg_match('/Windows NT 6\.3/i', $userAgent)) {
|
|
||||||
$os = 'Windows 8.1';
|
|
||||||
} elseif (preg_match('/Windows NT 6\.2/i', $userAgent)) {
|
|
||||||
$os = 'Windows 8';
|
|
||||||
} elseif (preg_match('/Windows NT 6\.1/i', $userAgent)) {
|
|
||||||
$os = 'Windows 7';
|
|
||||||
} elseif (preg_match('/Windows/i', $userAgent)) {
|
|
||||||
$os = 'Windows';
|
|
||||||
} elseif (preg_match('/Macintosh|Mac OS X/i', $userAgent)) {
|
|
||||||
$os = 'macOS';
|
|
||||||
} elseif (preg_match('/Linux/i', $userAgent) && !preg_match('/Android/i', $userAgent)) {
|
|
||||||
$os = 'Linux';
|
|
||||||
} elseif (preg_match('/iPhone/i', $userAgent)) {
|
|
||||||
$os = 'iOS';
|
|
||||||
$device = 'mobile';
|
|
||||||
$icon = 'smartphone';
|
|
||||||
} elseif (preg_match('/iPad/i', $userAgent)) {
|
|
||||||
$os = 'iPadOS';
|
|
||||||
$device = 'tablet';
|
|
||||||
$icon = 'tablet';
|
|
||||||
} elseif (preg_match('/Android/i', $userAgent)) {
|
|
||||||
$os = 'Android';
|
|
||||||
if (preg_match('/Mobile/i', $userAgent)) {
|
|
||||||
$device = 'mobile';
|
|
||||||
$icon = 'smartphone';
|
|
||||||
} else {
|
|
||||||
$device = 'tablet';
|
|
||||||
$icon = 'tablet';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'browser' => $browser,
|
|
||||||
'os' => $os,
|
|
||||||
'device' => $device,
|
|
||||||
'icon' => $icon,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
require_once dirname(__DIR__) . '/includes/RateLimiter.php';
|
||||||
require_once dirname(__DIR__) . '/includes/Encryption.php';
|
require_once dirname(__DIR__) . '/includes/Encryption.php';
|
||||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
require_once __DIR__ . '/handlers/totp-handlers.php';
|
||||||
|
|
||||||
use RobThree\Auth\TwoFactorAuth;
|
use RobThree\Auth\TwoFactorAuth;
|
||||||
use RobThree\Auth\TwoFactorAuthException;
|
use RobThree\Auth\TwoFactorAuthException;
|
||||||
@@ -32,16 +32,6 @@ header('Content-Type: application/json; charset=utf-8');
|
|||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
/** Lazy init - QRServerProvider dela externi HTTP, nepotrebujeme ho pro kazdy request */
|
|
||||||
function getTfa(): TwoFactorAuth
|
|
||||||
{
|
|
||||||
static $tfa = null;
|
|
||||||
if ($tfa === null) {
|
|
||||||
$tfa = new TwoFactorAuth(new QRServerProvider(), 'BOHA Automation');
|
|
||||||
}
|
|
||||||
return $tfa;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
@@ -80,409 +70,3 @@ try {
|
|||||||
error_log('TOTP error: ' . $e->getMessage());
|
error_log('TOTP error: ' . $e->getMessage());
|
||||||
errorResponse('Došlo k chybě', 500);
|
errorResponse('Došlo k chybě', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET ?action=status */
|
|
||||||
function handleStatus(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
$userId = $authData['user_id'];
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT totp_enabled FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'totp_enabled' => (bool) ($user['totp_enabled'] ?? false),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST ?action=setup - vygenerovat secret + QR URI (jeste neaktivuje 2FA) */
|
|
||||||
function handleSetup(PDO $pdo, TwoFactorAuth $tfa): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
$userId = $authData['user_id'];
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT totp_enabled, username, email FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if ($user['totp_enabled']) {
|
|
||||||
errorResponse('2FA je již aktivní. Nejdříve ji deaktivujte.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$secret = $tfa->createSecret();
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE users SET totp_secret = ? WHERE id = ?');
|
|
||||||
$stmt->execute([Encryption::encrypt($secret), $userId]);
|
|
||||||
|
|
||||||
$label = $user['email'] ?: $user['username'];
|
|
||||||
$qrUri = $tfa->getQRText($label, $secret);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'secret' => $secret,
|
|
||||||
'qr_uri' => $qrUri,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST ?action=enable { "code": "123456" } */
|
|
||||||
function handleEnable(PDO $pdo, TwoFactorAuth $tfa): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
$userId = $authData['user_id'];
|
|
||||||
$input = getJsonInput();
|
|
||||||
$code = trim($input['code'] ?? '');
|
|
||||||
|
|
||||||
if (empty($code)) {
|
|
||||||
errorResponse('Ověřovací kód je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$user['totp_secret']) {
|
|
||||||
errorResponse('Nejprve vygenerujte tajný klíč (setup)');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($user['totp_enabled']) {
|
|
||||||
errorResponse('2FA je již aktivní');
|
|
||||||
}
|
|
||||||
|
|
||||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
|
||||||
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
|
||||||
errorResponse('Neplatný ověřovací kód. Zkontrolujte čas na telefonu.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$backupCodes = generateBackupCodes();
|
|
||||||
$hashedCodes = array_map(fn ($c) => password_hash($c, PASSWORD_BCRYPT, ['cost' => 10]), $backupCodes);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE users SET totp_enabled = 1, totp_backup_codes = ? WHERE id = ?');
|
|
||||||
$stmt->execute([json_encode($hashedCodes), $userId]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 0], ['totp_enabled' => 1], 'Uživatel aktivoval 2FA');
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'backup_codes' => $backupCodes,
|
|
||||||
], '2FA bylo úspěšně aktivováno');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST ?action=disable { "code": "123456" } */
|
|
||||||
function handleDisable(PDO $pdo, TwoFactorAuth $tfa): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
$userId = $authData['user_id'];
|
|
||||||
$input = getJsonInput();
|
|
||||||
$code = trim($input['code'] ?? '');
|
|
||||||
|
|
||||||
if (empty($code)) {
|
|
||||||
errorResponse('Ověřovací kód je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT totp_secret, totp_enabled FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$user['totp_enabled']) {
|
|
||||||
errorResponse('2FA není aktivní');
|
|
||||||
}
|
|
||||||
|
|
||||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
|
||||||
if (!$tfa->verifyCode($decryptedSecret, $code)) {
|
|
||||||
errorResponse('Neplatný ověřovací kód');
|
|
||||||
}
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
'UPDATE users SET totp_enabled = 0, totp_secret = NULL,
|
|
||||||
totp_backup_codes = NULL WHERE id = ?'
|
|
||||||
);
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate('user', $userId, ['totp_enabled' => 1], ['totp_enabled' => 0], 'Uživatel deaktivoval 2FA');
|
|
||||||
|
|
||||||
successResponse(null, '2FA bylo deaktivováno');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST ?action=verify - overeni TOTP kodu pri loginu (pre-auth)
|
|
||||||
* Body: { "login_token": "...", "code": "123456", "remember": false }
|
|
||||||
*/
|
|
||||||
function handleVerify(PDO $pdo, TwoFactorAuth $tfa): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rateLimiter = new RateLimiter();
|
|
||||||
$rateLimiter->setFailClosed();
|
|
||||||
$rateLimiter->enforce('totp_2fa', 5);
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$loginToken = $input['login_token'] ?? '';
|
|
||||||
$code = trim($input['code'] ?? '');
|
|
||||||
$remember = (bool) ($input['remember'] ?? false);
|
|
||||||
|
|
||||||
if (empty($loginToken) || empty($code)) {
|
|
||||||
errorResponse('Přihlašovací token a ověřovací kód jsou povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenData = verifyLoginToken($pdo, $loginToken);
|
|
||||||
if (!$tokenData) {
|
|
||||||
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $tokenData['user_id'];
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.id = ? AND u.totp_enabled = 1
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
errorResponse('Uživatel nenalezen nebo 2FA není aktivní', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$decryptedSecret = decryptTotpSecret($user['totp_secret']);
|
|
||||||
if (!$tfa->verifyCode($decryptedSecret, $code, 1)) {
|
|
||||||
errorResponse('Neplatný ověřovací kód');
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteLoginToken($pdo, $loginToken);
|
|
||||||
completeLogin($pdo, $user, $remember);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST ?action=backup_verify { "login_token": "...", "code": "XXXXXXXX", "remember": false } */
|
|
||||||
function handleBackupVerify(PDO $pdo): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$rateLimiter = new RateLimiter();
|
|
||||||
$rateLimiter->setFailClosed();
|
|
||||||
$rateLimiter->enforce('totp_2fa', 5);
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$loginToken = $input['login_token'] ?? '';
|
|
||||||
$code = strtoupper(trim($input['code'] ?? ''));
|
|
||||||
$remember = (bool) ($input['remember'] ?? false);
|
|
||||||
|
|
||||||
if (empty($loginToken) || empty($code)) {
|
|
||||||
errorResponse('Přihlašovací token a záložní kód jsou povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenData = verifyLoginToken($pdo, $loginToken);
|
|
||||||
if (!$tokenData) {
|
|
||||||
errorResponse('Neplatný nebo expirovaný přihlašovací token. Přihlaste se znovu.', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$userId = $tokenData['user_id'];
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT u.*, r.name as role_name, r.display_name as role_display_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.id = ? AND u.totp_enabled = 1
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$user || !$user['totp_backup_codes']) {
|
|
||||||
errorResponse('Uživatel nenalezen nebo nemá záložní kódy', 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$hashedCodes = json_decode($user['totp_backup_codes'], true);
|
|
||||||
$matched = false;
|
|
||||||
$remainingCodes = [];
|
|
||||||
|
|
||||||
foreach ($hashedCodes as $hashed) {
|
|
||||||
if (!$matched && password_verify($code, $hashed)) {
|
|
||||||
$matched = true;
|
|
||||||
} else {
|
|
||||||
$remainingCodes[] = $hashed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$matched) {
|
|
||||||
errorResponse('Neplatný záložní kód');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('UPDATE users SET totp_backup_codes = ? WHERE id = ?');
|
|
||||||
$stmt->execute([json_encode($remainingCodes), $userId]);
|
|
||||||
|
|
||||||
deleteLoginToken($pdo, $loginToken);
|
|
||||||
completeLogin($pdo, $user, $remember);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET ?action=get_required (admin only) */
|
|
||||||
function handleGetRequired(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
requirePermission($authData, 'settings.security');
|
|
||||||
|
|
||||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'require_2fa' => (bool) $stmt->fetchColumn(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST ?action=set_required { "required": true/false } (admin only) */
|
|
||||||
function handleSetRequired(PDO $pdo): void
|
|
||||||
{
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
errorResponse('Metoda není povolena', 405);
|
|
||||||
}
|
|
||||||
|
|
||||||
$authData = JWTAuth::requireAuth();
|
|
||||||
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
|
|
||||||
requirePermission($authData, 'settings.security');
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
$required = (bool) ($input['required'] ?? false);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare("UPDATE company_settings SET require_2fa = ? LIMIT 1");
|
|
||||||
$stmt->execute([$required ? 1 : 0]);
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'require_2fa' => $required,
|
|
||||||
], $required ? '2FA je nyní povinná pro všechny uživatele' : '2FA již není povinná');
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helper functions ---
|
|
||||||
|
|
||||||
/** Desifrovat TOTP secret z DB (zpetne kompatibilni s plaintextem pred migraci) */
|
|
||||||
function decryptTotpSecret(string $value): string
|
|
||||||
{
|
|
||||||
if (Encryption::isEncrypted($value)) {
|
|
||||||
return Encryption::decrypt($value);
|
|
||||||
}
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generovat 8 nahodnych backup kodu
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
function generateBackupCodes(int $count = 8): array
|
|
||||||
{
|
|
||||||
$codes = [];
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
|
||||||
$codes[] = strtoupper(bin2hex(random_bytes(4))); // 8-char hex
|
|
||||||
}
|
|
||||||
return $codes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Docasny login token pro 2FA (5 min) */
|
|
||||||
function createLoginToken(PDO $pdo, int $userId): string
|
|
||||||
{
|
|
||||||
$token = bin2hex(random_bytes(32));
|
|
||||||
$hashedToken = hash('sha256', $token);
|
|
||||||
$expiresAt = date('Y-m-d H:i:s', time() + 300); // 5 minutes
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE user_id = ? OR expires_at < NOW()');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO totp_login_tokens (user_id, token_hash, expires_at)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId, $hashedToken, $expiresAt]);
|
|
||||||
|
|
||||||
return $token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overit login token
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
function verifyLoginToken(PDO $pdo, string $token): ?array
|
|
||||||
{
|
|
||||||
$hashedToken = hash('sha256', $token);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT * FROM totp_login_tokens
|
|
||||||
WHERE token_hash = ? AND expires_at > NOW()
|
|
||||||
');
|
|
||||||
$stmt->execute([$hashedToken]);
|
|
||||||
return $stmt->fetch() ?: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Smazat login token po pouziti */
|
|
||||||
function deleteLoginToken(PDO $pdo, string $token): void
|
|
||||||
{
|
|
||||||
$hashedToken = hash('sha256', $token);
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM totp_login_tokens WHERE token_hash = ?');
|
|
||||||
$stmt->execute([$hashedToken]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dokoncit login po uspesnem 2FA - vydat JWT + refresh token
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $user
|
|
||||||
*/
|
|
||||||
function completeLogin(PDO $pdo, array $user, bool $remember): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE users SET failed_login_attempts = 0, locked_until = NULL, last_login = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$user['id']]);
|
|
||||||
|
|
||||||
$userData = [
|
|
||||||
'id' => $user['id'],
|
|
||||||
'username' => $user['username'],
|
|
||||||
'email' => $user['email'],
|
|
||||||
'first_name' => $user['first_name'],
|
|
||||||
'last_name' => $user['last_name'],
|
|
||||||
'role' => $user['role_name'] ?? null,
|
|
||||||
'role_display' => $user['role_display_name'] ?? $user['role_name'] ?? null,
|
|
||||||
'is_admin' => ($user['role_name'] ?? '') === 'admin',
|
|
||||||
];
|
|
||||||
|
|
||||||
$accessToken = JWTAuth::generateAccessToken($userData);
|
|
||||||
JWTAuth::generateRefreshToken($user['id'], $remember);
|
|
||||||
|
|
||||||
AuditLog::logLogin($user['id'], $user['username']);
|
|
||||||
|
|
||||||
$stmt = $pdo->query("SELECT require_2fa FROM company_settings LIMIT 1");
|
|
||||||
$require2FA = (bool) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'access_token' => $accessToken,
|
|
||||||
'expires_in' => JWTAuth::getAccessTokenExpiry(),
|
|
||||||
'user' => [
|
|
||||||
'id' => $userData['id'],
|
|
||||||
'username' => $userData['username'],
|
|
||||||
'email' => $userData['email'],
|
|
||||||
'full_name' => trim($userData['first_name'] . ' ' . $userData['last_name']),
|
|
||||||
'role' => $userData['role'],
|
|
||||||
'role_display' => $userData['role_display'],
|
|
||||||
'is_admin' => $userData['is_admin'],
|
|
||||||
'permissions' => JWTAuth::getUserPermissions($user['id']),
|
|
||||||
'totp_enabled' => true,
|
|
||||||
'require_2fa' => $require2FA,
|
|
||||||
],
|
|
||||||
], 'Přihlášení úspěšné');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ require_once dirname(__DIR__) . '/config.php';
|
|||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
require_once dirname(__DIR__) . '/includes/AttendanceHelpers.php';
|
||||||
|
require_once __DIR__ . '/handlers/trips-handlers.php';
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
@@ -129,660 +130,3 @@ try {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function getLastKmForVehicle(PDO $pdo, int $vehicleId): int
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT COALESCE(
|
|
||||||
(SELECT MAX(end_km) FROM trips WHERE vehicle_id = ?),
|
|
||||||
(SELECT initial_km FROM vehicles WHERE id = ?),
|
|
||||||
0
|
|
||||||
) as last_km
|
|
||||||
');
|
|
||||||
$stmt->execute([$vehicleId, $vehicleId]);
|
|
||||||
$result = $stmt->fetch();
|
|
||||||
|
|
||||||
return $result ? (int)$result['last_km'] : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatKm(int $km): string
|
|
||||||
{
|
|
||||||
return number_format($km, 0, ',', ' ') . ' km';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// GET Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Current month trips (filtered to current user)
|
|
||||||
*/
|
|
||||||
function handleGetCurrent(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$month = validateMonth();
|
|
||||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
|
||||||
$startDate = "{$month}-01";
|
|
||||||
$endDate = date('Y-m-t', strtotime($startDate));
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
|
||||||
FROM trips t
|
|
||||||
JOIN vehicles v ON t.vehicle_id = v.id
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.trip_date BETWEEN ? AND ?
|
|
||||||
AND t.user_id = ?
|
|
||||||
";
|
|
||||||
$params = [$startDate, $endDate, $userId];
|
|
||||||
|
|
||||||
if ($vehicleId) {
|
|
||||||
$sql .= ' AND t.vehicle_id = ?';
|
|
||||||
$params[] = $vehicleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$trips = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get active vehicles for selection
|
|
||||||
$stmt = $pdo->query('SELECT id, spz, name, brand, model FROM vehicles WHERE is_active = 1 ORDER BY name');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
$totalDistance = 0;
|
|
||||||
$businessDistance = 0;
|
|
||||||
$privateDistance = 0;
|
|
||||||
|
|
||||||
foreach ($trips as $trip) {
|
|
||||||
$totalDistance += $trip['distance'];
|
|
||||||
if ($trip['is_business']) {
|
|
||||||
$businessDistance += $trip['distance'];
|
|
||||||
} else {
|
|
||||||
$privateDistance += $trip['distance'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'trips' => $trips,
|
|
||||||
'vehicles' => $vehicles,
|
|
||||||
'month' => $month,
|
|
||||||
'totals' => [
|
|
||||||
'total' => $totalDistance,
|
|
||||||
'business' => $businessDistance,
|
|
||||||
'private' => $privateDistance,
|
|
||||||
'count' => count($trips),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Trip history with filters (filtered to current user)
|
|
||||||
*/
|
|
||||||
function handleGetHistory(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$month = validateMonth();
|
|
||||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
|
||||||
|
|
||||||
$startDate = "{$month}-01";
|
|
||||||
$endDate = date('Y-m-t', strtotime($startDate));
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
|
||||||
FROM trips t
|
|
||||||
JOIN vehicles v ON t.vehicle_id = v.id
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.trip_date BETWEEN ? AND ?
|
|
||||||
AND t.user_id = ?
|
|
||||||
";
|
|
||||||
$params = [$startDate, $endDate, $userId];
|
|
||||||
|
|
||||||
if ($vehicleId) {
|
|
||||||
$sql .= ' AND t.vehicle_id = ?';
|
|
||||||
$params[] = $vehicleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$trips = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get vehicles for filter
|
|
||||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles WHERE is_active = 1 ORDER BY name');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
$totalDistance = 0;
|
|
||||||
$businessDistance = 0;
|
|
||||||
|
|
||||||
foreach ($trips as $trip) {
|
|
||||||
$totalDistance += $trip['distance'];
|
|
||||||
if ($trip['is_business']) {
|
|
||||||
$businessDistance += $trip['distance'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'trips' => $trips,
|
|
||||||
'vehicles' => $vehicles,
|
|
||||||
'month' => $month,
|
|
||||||
'totals' => [
|
|
||||||
'total' => $totalDistance,
|
|
||||||
'business' => $businessDistance,
|
|
||||||
'count' => count($trips),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Admin view of all trips
|
|
||||||
*/
|
|
||||||
function handleGetAdmin(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$dateFrom = $_GET['date_from'] ?? null;
|
|
||||||
$dateTo = $_GET['date_to'] ?? null;
|
|
||||||
$vehicleId = isset($_GET['vehicle_id']) ? (int)$_GET['vehicle_id'] : null;
|
|
||||||
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
|
||||||
|
|
||||||
// Default to current month if no dates provided
|
|
||||||
if (!$dateFrom || !$dateTo) {
|
|
||||||
$month = date('Y-m');
|
|
||||||
$startDate = "{$month}-01";
|
|
||||||
$endDate = date('Y-m-t', strtotime($startDate));
|
|
||||||
} else {
|
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
|
||||||
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
|
||||||
}
|
|
||||||
$startDate = $dateFrom;
|
|
||||||
$endDate = $dateTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT t.*, v.spz, v.name as vehicle_name,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
|
||||||
FROM trips t
|
|
||||||
JOIN vehicles v ON t.vehicle_id = v.id
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.trip_date BETWEEN ? AND ?
|
|
||||||
";
|
|
||||||
$params = [$startDate, $endDate];
|
|
||||||
|
|
||||||
if ($vehicleId) {
|
|
||||||
$sql .= ' AND t.vehicle_id = ?';
|
|
||||||
$params[] = $vehicleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($filterUserId) {
|
|
||||||
$sql .= ' AND t.user_id = ?';
|
|
||||||
$params[] = $filterUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql .= ' ORDER BY t.trip_date DESC, t.start_km DESC';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$trips = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get vehicles for filter
|
|
||||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get users for filter
|
|
||||||
$stmt = $pdo->query(
|
|
||||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
|
||||||
);
|
|
||||||
$users = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
$totalDistance = 0;
|
|
||||||
$businessDistance = 0;
|
|
||||||
|
|
||||||
foreach ($trips as $trip) {
|
|
||||||
$totalDistance += $trip['distance'];
|
|
||||||
if ($trip['is_business']) {
|
|
||||||
$businessDistance += $trip['distance'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'trips' => $trips,
|
|
||||||
'vehicles' => $vehicles,
|
|
||||||
'users' => $users,
|
|
||||||
'date_from' => $startDate,
|
|
||||||
'date_to' => $endDate,
|
|
||||||
'totals' => [
|
|
||||||
'total' => $totalDistance,
|
|
||||||
'business' => $businessDistance,
|
|
||||||
'count' => count($trips),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - All vehicles (admin)
|
|
||||||
*/
|
|
||||||
function handleGetVehicles(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT v.*, COUNT(t.id) as trip_count,
|
|
||||||
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
|
||||||
FROM vehicles v
|
|
||||||
LEFT JOIN trips t ON t.vehicle_id = v.id
|
|
||||||
GROUP BY v.id
|
|
||||||
ORDER BY v.is_active DESC, v.name
|
|
||||||
');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse(['vehicles' => $vehicles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Active vehicles for selection
|
|
||||||
*/
|
|
||||||
function handleGetActiveVehicles(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT v.id, v.spz, v.name, v.brand, v.model,
|
|
||||||
COALESCE(MAX(t.end_km), v.initial_km) as current_km
|
|
||||||
FROM vehicles v
|
|
||||||
LEFT JOIN trips t ON t.vehicle_id = v.id
|
|
||||||
WHERE v.is_active = 1
|
|
||||||
GROUP BY v.id
|
|
||||||
ORDER BY v.name
|
|
||||||
');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse(['vehicles' => $vehicles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Last km for vehicle
|
|
||||||
*/
|
|
||||||
function handleGetLastKm(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$vehicleId = (int)($_GET['vehicle_id'] ?? 0);
|
|
||||||
if (!$vehicleId) {
|
|
||||||
errorResponse('Vehicle ID je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastKm = getLastKmForVehicle($pdo, $vehicleId);
|
|
||||||
successResponse(['last_km' => $lastKm]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// POST Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Create trip
|
|
||||||
*/
|
|
||||||
function handleCreateTrip(PDO $pdo, int $userId): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$vehicleId = (int)($input['vehicle_id'] ?? 0);
|
|
||||||
$tripDate = $input['trip_date'] ?? '';
|
|
||||||
$startKm = (int)($input['start_km'] ?? 0);
|
|
||||||
$endKm = (int)($input['end_km'] ?? 0);
|
|
||||||
$routeFrom = trim($input['route_from'] ?? '');
|
|
||||||
$routeTo = trim($input['route_to'] ?? '');
|
|
||||||
$isBusiness = (int)($input['is_business'] ?? 1);
|
|
||||||
$notes = trim($input['notes'] ?? '');
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!$vehicleId) {
|
|
||||||
errorResponse('Vyberte vozidlo');
|
|
||||||
}
|
|
||||||
if (!$tripDate) {
|
|
||||||
errorResponse('Datum jízdy je povinné');
|
|
||||||
}
|
|
||||||
if (!$startKm) {
|
|
||||||
errorResponse('Počáteční stav km je povinný');
|
|
||||||
}
|
|
||||||
if (!$endKm) {
|
|
||||||
errorResponse('Konečný stav km je povinný');
|
|
||||||
}
|
|
||||||
if (!$routeFrom) {
|
|
||||||
errorResponse('Místo odjezdu je povinné');
|
|
||||||
}
|
|
||||||
if (!$routeTo) {
|
|
||||||
errorResponse('Místo příjezdu je povinné');
|
|
||||||
}
|
|
||||||
if ($endKm <= $startKm) {
|
|
||||||
errorResponse('Konečný stav km musí být větší než počáteční');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check vehicle exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM vehicles WHERE id = ? AND is_active = 1');
|
|
||||||
$stmt->execute([$vehicleId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Vozidlo neexistuje nebo není aktivní');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO trips (vehicle_id, user_id, trip_date, start_km, end_km, route_from, route_to, is_business, notes)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([
|
|
||||||
$vehicleId, $userId, $tripDate, $startKm, $endKm,
|
|
||||||
$routeFrom, $routeTo, $isBusiness, $notes ?: null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$newId = (int)$pdo->lastInsertId();
|
|
||||||
AuditLog::logCreate('trips', $newId, $input, 'Vytvořen záznam jízdy');
|
|
||||||
|
|
||||||
successResponse(['id' => $newId], 'Jízda byla zaznamenána');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Create/update vehicle (admin)
|
|
||||||
*/
|
|
||||||
function handleVehicle(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$id = (int)($input['id'] ?? 0);
|
|
||||||
$spz = strtoupper(trim($input['spz'] ?? ''));
|
|
||||||
$name = trim($input['name'] ?? '');
|
|
||||||
$brand = trim($input['brand'] ?? '');
|
|
||||||
$model = trim($input['model'] ?? '');
|
|
||||||
$initialKm = (int)($input['initial_km'] ?? 0);
|
|
||||||
$isActive = isset($input['is_active']) ? (int)$input['is_active'] : 1;
|
|
||||||
|
|
||||||
if (!$spz) {
|
|
||||||
errorResponse('SPZ je povinná');
|
|
||||||
}
|
|
||||||
if (!$name) {
|
|
||||||
errorResponse('Název je povinný');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($id) {
|
|
||||||
// Update
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE vehicles
|
|
||||||
SET spz = ?, name = ?, brand = ?, model = ?, initial_km = ?, is_active = ?
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive, $id]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate('vehicles', $id, [], $input, 'Upraveno vozidlo');
|
|
||||||
successResponse(null, 'Vozidlo bylo aktualizováno');
|
|
||||||
} else {
|
|
||||||
// Create
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO vehicles (spz, name, brand, model, initial_km, is_active)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
|
|
||||||
try {
|
|
||||||
$stmt->execute([$spz, $name, $brand ?: null, $model ?: null, $initialKm, $isActive]);
|
|
||||||
$newId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
AuditLog::logCreate('vehicles', $newId, $input, 'Vytvořeno vozidlo');
|
|
||||||
successResponse(['id' => $newId], 'Vozidlo bylo vytvořeno');
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
if ($e->getCode() == 23000) {
|
|
||||||
errorResponse('Vozidlo s touto SPZ již existuje');
|
|
||||||
}
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// PUT Handler
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT - Update trip
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleUpdateTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$trip = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$trip) {
|
|
||||||
errorResponse('Záznam nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission - own trips or trips.admin
|
|
||||||
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
|
||||||
errorResponse('Nemáte oprávnění upravit tento záznam', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$vehicleId = (int)($input['vehicle_id'] ?? $trip['vehicle_id']);
|
|
||||||
$tripDate = $input['trip_date'] ?? $trip['trip_date'];
|
|
||||||
$startKm = (int)($input['start_km'] ?? $trip['start_km']);
|
|
||||||
$endKm = (int)($input['end_km'] ?? $trip['end_km']);
|
|
||||||
$routeFrom = trim($input['route_from'] ?? $trip['route_from']);
|
|
||||||
$routeTo = trim($input['route_to'] ?? $trip['route_to']);
|
|
||||||
$isBusiness = isset($input['is_business']) ? (int)$input['is_business'] : $trip['is_business'];
|
|
||||||
$notes = trim($input['notes'] ?? $trip['notes'] ?? '');
|
|
||||||
|
|
||||||
if ($endKm <= $startKm) {
|
|
||||||
errorResponse('Konečný stav km musí být větší než počáteční');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE trips
|
|
||||||
SET vehicle_id = ?, trip_date = ?, start_km = ?, end_km = ?,
|
|
||||||
route_from = ?, route_to = ?, is_business = ?, notes = ?
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$vehicleId, $tripDate, $startKm, $endKm, $routeFrom, $routeTo, $isBusiness, $notes ?: null, $id]);
|
|
||||||
|
|
||||||
AuditLog::logUpdate('trips', $id, $trip, $input, 'Upraven záznam jízdy');
|
|
||||||
|
|
||||||
successResponse(null, 'Záznam byl aktualizován');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// DELETE Handlers
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete trip
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleDeleteTrip(PDO $pdo, int $id, int $userId, array $authData): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM trips WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$trip = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$trip) {
|
|
||||||
errorResponse('Záznam nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check permission - own trips or trips.admin
|
|
||||||
if ($trip['user_id'] !== $userId && !hasPermission($authData, 'trips.admin')) {
|
|
||||||
errorResponse('Nemáte oprávnění smazat tento záznam', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM trips WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
AuditLog::logDelete('trips', $id, $trip, 'Smazán záznam jízdy');
|
|
||||||
|
|
||||||
successResponse(null, 'Záznam byl smazán');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete vehicle (admin)
|
|
||||||
*/
|
|
||||||
function handleDeleteVehicle(PDO $pdo, int $id): void
|
|
||||||
{
|
|
||||||
if (!$id) {
|
|
||||||
errorResponse('ID je povinné');
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM vehicles WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$vehicle = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$vehicle) {
|
|
||||||
errorResponse('Vozidlo nebylo nalezeno', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if vehicle has trips
|
|
||||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM trips WHERE vehicle_id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
$tripCount = $stmt->fetchColumn();
|
|
||||||
|
|
||||||
if ($tripCount > 0) {
|
|
||||||
errorResponse(
|
|
||||||
"Nelze smazat vozidlo s {$tripCount} záznamy jízd. Nejprve smažte záznamy jízd nebo deaktivujte vozidlo."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM vehicles WHERE id = ?');
|
|
||||||
$stmt->execute([$id]);
|
|
||||||
|
|
||||||
AuditLog::logDelete('vehicles', $id, $vehicle, 'Smazáno vozidlo');
|
|
||||||
|
|
||||||
successResponse(null, 'Vozidlo bylo smazáno');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Print Handler
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format date range for display
|
|
||||||
*/
|
|
||||||
function formatPeriodName(string $startDate, string $endDate): string
|
|
||||||
{
|
|
||||||
$start = new DateTime($startDate);
|
|
||||||
$end = new DateTime($endDate);
|
|
||||||
|
|
||||||
// If same month
|
|
||||||
if ($start->format('Y-m') === $end->format('Y-m')) {
|
|
||||||
return getCzechMonthName((int)$start->format('n')) . ' ' . $start->format('Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
// If same year
|
|
||||||
if ($start->format('Y') === $end->format('Y')) {
|
|
||||||
return $start->format('j.n.') . ' - ' . $end->format('j.n.Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different years
|
|
||||||
return $start->format('j.n.Y') . ' - ' . $end->format('j.n.Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - Print data for trips (admin)
|
|
||||||
*/
|
|
||||||
function handleGetPrint(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$dateFrom = $_GET['date_from'] ?? null;
|
|
||||||
$dateTo = $_GET['date_to'] ?? null;
|
|
||||||
$vehicleId = isset($_GET['vehicle_id']) && $_GET['vehicle_id'] !== '' ? (int)$_GET['vehicle_id'] : null;
|
|
||||||
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
|
||||||
|
|
||||||
// Default to current month if no dates provided
|
|
||||||
if (!$dateFrom || !$dateTo) {
|
|
||||||
$startDate = date('Y-m-01');
|
|
||||||
$endDate = date('Y-m-t');
|
|
||||||
} else {
|
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
|
||||||
errorResponse('Neplatný formát data (očekáváno YYYY-MM-DD)');
|
|
||||||
}
|
|
||||||
$startDate = $dateFrom;
|
|
||||||
$endDate = $dateTo;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql = "
|
|
||||||
SELECT t.*, v.spz, v.name as vehicle_name, v.brand, v.model,
|
|
||||||
CONCAT(u.first_name, ' ', u.last_name) as driver_name
|
|
||||||
FROM trips t
|
|
||||||
JOIN vehicles v ON t.vehicle_id = v.id
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.trip_date BETWEEN ? AND ?
|
|
||||||
";
|
|
||||||
$params = [$startDate, $endDate];
|
|
||||||
|
|
||||||
if ($vehicleId) {
|
|
||||||
$sql .= ' AND t.vehicle_id = ?';
|
|
||||||
$params[] = $vehicleId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($filterUserId) {
|
|
||||||
$sql .= ' AND t.user_id = ?';
|
|
||||||
$params[] = $filterUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sql .= ' ORDER BY t.trip_date ASC, t.start_km ASC';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare($sql);
|
|
||||||
$stmt->execute($params);
|
|
||||||
$trips = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get vehicles for filter
|
|
||||||
$stmt = $pdo->query('SELECT id, spz, name FROM vehicles ORDER BY name');
|
|
||||||
$vehicles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get users for filter
|
|
||||||
$stmt = $pdo->query(
|
|
||||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name FROM users WHERE is_active = 1 ORDER BY last_name"
|
|
||||||
);
|
|
||||||
$users = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Calculate totals
|
|
||||||
$totalDistance = 0;
|
|
||||||
$businessDistance = 0;
|
|
||||||
$privateDistance = 0;
|
|
||||||
|
|
||||||
foreach ($trips as $trip) {
|
|
||||||
$totalDistance += $trip['distance'];
|
|
||||||
if ($trip['is_business']) {
|
|
||||||
$businessDistance += $trip['distance'];
|
|
||||||
} else {
|
|
||||||
$privateDistance += $trip['distance'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get selected vehicle/user names for header
|
|
||||||
$selectedVehicleName = '';
|
|
||||||
if ($vehicleId) {
|
|
||||||
$stmt = $pdo->prepare("SELECT CONCAT(spz, ' - ', name) as name FROM vehicles WHERE id = ?");
|
|
||||||
$stmt->execute([$vehicleId]);
|
|
||||||
$v = $stmt->fetch();
|
|
||||||
$selectedVehicleName = $v ? $v['name'] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectedUserName = '';
|
|
||||||
if ($filterUserId) {
|
|
||||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
|
||||||
$stmt->execute([$filterUserId]);
|
|
||||||
$u = $stmt->fetch();
|
|
||||||
$selectedUserName = $u ? $u['name'] : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'trips' => $trips,
|
|
||||||
'vehicles' => $vehicles,
|
|
||||||
'users' => $users,
|
|
||||||
'date_from' => $startDate,
|
|
||||||
'date_to' => $endDate,
|
|
||||||
'period_name' => formatPeriodName($startDate, $endDate),
|
|
||||||
'selected_vehicle' => $vehicleId,
|
|
||||||
'selected_vehicle_name' => $selectedVehicleName,
|
|
||||||
'selected_user' => $filterUserId,
|
|
||||||
'selected_user_name' => $selectedUserName,
|
|
||||||
'totals' => [
|
|
||||||
'total' => $totalDistance,
|
|
||||||
'business' => $businessDistance,
|
|
||||||
'private' => $privateDistance,
|
|
||||||
'count' => count($trips),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ declare(strict_types=1);
|
|||||||
require_once dirname(__DIR__) . '/config.php';
|
require_once dirname(__DIR__) . '/config.php';
|
||||||
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
|
||||||
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
require_once dirname(__DIR__) . '/includes/AuditLog.php';
|
||||||
|
require_once __DIR__ . '/handlers/users-handlers.php';
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
setCorsHeaders();
|
setCorsHeaders();
|
||||||
@@ -70,273 +71,3 @@ try {
|
|||||||
errorResponse('Chyba databáze', 500);
|
errorResponse('Chyba databáze', 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* GET - List all users
|
|
||||||
*/
|
|
||||||
function handleGetUser(PDO $pdo): void
|
|
||||||
{
|
|
||||||
$stmt = $pdo->query('
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username,
|
|
||||||
u.email,
|
|
||||||
u.first_name,
|
|
||||||
u.last_name,
|
|
||||||
u.role_id,
|
|
||||||
u.is_active,
|
|
||||||
u.last_login,
|
|
||||||
u.created_at,
|
|
||||||
r.name as role_name,
|
|
||||||
r.display_name as role_display_name
|
|
||||||
FROM users u
|
|
||||||
LEFT JOIN roles r ON u.role_id = r.id
|
|
||||||
ORDER BY u.created_at DESC
|
|
||||||
');
|
|
||||||
$users = $stmt->fetchAll();
|
|
||||||
|
|
||||||
// Get roles for dropdown
|
|
||||||
$stmt = $pdo->query('SELECT id, name, display_name FROM roles ORDER BY id');
|
|
||||||
$roles = $stmt->fetchAll();
|
|
||||||
|
|
||||||
successResponse([
|
|
||||||
'users' => $users,
|
|
||||||
'roles' => $roles,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST - Create new user
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleCreateUser(PDO $pdo, array $authData): void
|
|
||||||
{
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
$requiredFields = [
|
|
||||||
'username' => 'Uživatelské jméno',
|
|
||||||
'email' => 'E-mail',
|
|
||||||
'password' => 'Heslo',
|
|
||||||
'first_name' => 'Jméno',
|
|
||||||
'last_name' => 'Příjmení',
|
|
||||||
'role_id' => 'Role',
|
|
||||||
];
|
|
||||||
foreach ($requiredFields as $field => $label) {
|
|
||||||
if (empty($input[$field])) {
|
|
||||||
errorResponse("$label je povinné");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$username = sanitize($input['username']);
|
|
||||||
$email = sanitize($input['email']);
|
|
||||||
$password = $input['password'];
|
|
||||||
$firstName = sanitize($input['first_name']);
|
|
||||||
$lastName = sanitize($input['last_name']);
|
|
||||||
$roleId = (int) $input['role_id'];
|
|
||||||
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : 1;
|
|
||||||
|
|
||||||
// Non-admin nesmí přiřadit admin roli
|
|
||||||
if (!($authData['user']['is_admin'] ?? false)) {
|
|
||||||
$stmt = $pdo->prepare('SELECT name FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
$targetRole = $stmt->fetch();
|
|
||||||
if ($targetRole && $targetRole['name'] === 'admin') {
|
|
||||||
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
if (!isValidEmail($email)) {
|
|
||||||
errorResponse('Neplatný formát e-mailu');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate password length
|
|
||||||
if (strlen($password) < 8) {
|
|
||||||
errorResponse('Heslo musí mít alespoň 8 znaků');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check username uniqueness
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ?');
|
|
||||||
$stmt->execute([$username]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('Uživatelské jméno již existuje');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check email uniqueness
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ?');
|
|
||||||
$stmt->execute([$email]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('E-mail již existuje');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate role exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
if (!$stmt->fetch()) {
|
|
||||||
errorResponse('Neplatná role');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
$passwordHash = password_hash($password, PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
|
||||||
|
|
||||||
// Insert user
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
INSERT INTO users (username, email, password_hash, first_name, last_name, role_id, is_active)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
');
|
|
||||||
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive]);
|
|
||||||
|
|
||||||
$newUserId = (int)$pdo->lastInsertId();
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
AuditLog::logCreate('user', $newUserId, [
|
|
||||||
'username' => $username,
|
|
||||||
'email' => $email,
|
|
||||||
'first_name' => $firstName,
|
|
||||||
'last_name' => $lastName,
|
|
||||||
'role_id' => $roleId,
|
|
||||||
'is_active' => $isActive,
|
|
||||||
], "Vytvořen uživatel '$username'");
|
|
||||||
|
|
||||||
successResponse(['id' => $newUserId], 'Uživatel byl úspěšně vytvořen');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PUT - Update user
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function handleUpdateUser(PDO $pdo, int $userId, int $currentUserId, array $authData): void
|
|
||||||
{
|
|
||||||
// Get existing user
|
|
||||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$existingUser = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$existingUser) {
|
|
||||||
errorResponse('Uživatel nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = getJsonInput();
|
|
||||||
|
|
||||||
$username = isset($input['username']) ? sanitize($input['username']) : $existingUser['username'];
|
|
||||||
$email = isset($input['email']) ? sanitize($input['email']) : $existingUser['email'];
|
|
||||||
$firstName = isset($input['first_name']) ? sanitize($input['first_name']) : $existingUser['first_name'];
|
|
||||||
$lastName = isset($input['last_name']) ? sanitize($input['last_name']) : $existingUser['last_name'];
|
|
||||||
$roleId = isset($input['role_id']) ? (int) $input['role_id'] : $existingUser['role_id'];
|
|
||||||
$isActive = isset($input['is_active']) ? ($input['is_active'] ? 1 : 0) : $existingUser['is_active'];
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
if (!isValidEmail($email)) {
|
|
||||||
errorResponse('Neplatný formát e-mailu');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check username uniqueness (excluding current user)
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = ? AND id != ?');
|
|
||||||
$stmt->execute([$username, $userId]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('Uživatelské jméno již existuje');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check email uniqueness (excluding current user)
|
|
||||||
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = ? AND id != ?');
|
|
||||||
$stmt->execute([$email, $userId]);
|
|
||||||
if ($stmt->fetch()) {
|
|
||||||
errorResponse('E-mail již existuje');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate role exists
|
|
||||||
$stmt = $pdo->prepare('SELECT id, name FROM roles WHERE id = ?');
|
|
||||||
$stmt->execute([$roleId]);
|
|
||||||
$targetRole = $stmt->fetch();
|
|
||||||
if (!$targetRole) {
|
|
||||||
errorResponse('Neplatná role');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-admin nesmí přiřadit admin roli
|
|
||||||
if (!($authData['user']['is_admin'] ?? false) && $targetRole['name'] === 'admin') {
|
|
||||||
errorResponse('Nemáte oprávnění přiřadit roli administrátora', 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user
|
|
||||||
if (!empty($input['password'])) {
|
|
||||||
// Validate password length
|
|
||||||
if (strlen($input['password']) < 8) {
|
|
||||||
errorResponse('Heslo musí mít alespoň 8 znaků');
|
|
||||||
}
|
|
||||||
|
|
||||||
$passwordHash = password_hash($input['password'], PASSWORD_BCRYPT, ['cost' => BCRYPT_COST]);
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE users
|
|
||||||
SET username = ?, email = ?, password_hash = ?,
|
|
||||||
first_name = ?, last_name = ?, role_id = ?,
|
|
||||||
is_active = ?, password_changed_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$username, $email, $passwordHash, $firstName, $lastName, $roleId, $isActive, $userId]);
|
|
||||||
} else {
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
UPDATE users
|
|
||||||
SET username = ?, email = ?, first_name = ?, last_name = ?, role_id = ?, is_active = ?
|
|
||||||
WHERE id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$username, $email, $firstName, $lastName, $roleId, $isActive, $userId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: With JWT, user data is in the token - no session to update
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
AuditLog::logUpdate('user', $userId, [
|
|
||||||
'username' => $existingUser['username'],
|
|
||||||
'email' => $existingUser['email'],
|
|
||||||
'first_name' => $existingUser['first_name'],
|
|
||||||
'last_name' => $existingUser['last_name'],
|
|
||||||
'role_id' => $existingUser['role_id'],
|
|
||||||
'is_active' => $existingUser['is_active'],
|
|
||||||
], [
|
|
||||||
'username' => $username,
|
|
||||||
'email' => $email,
|
|
||||||
'first_name' => $firstName,
|
|
||||||
'last_name' => $lastName,
|
|
||||||
'role_id' => $roleId,
|
|
||||||
'is_active' => $isActive,
|
|
||||||
], "Upraven uživatel '$username'");
|
|
||||||
|
|
||||||
successResponse(null, 'Uživatel byl úspěšně aktualizován');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE - Delete user
|
|
||||||
*/
|
|
||||||
function handleDeleteUser(PDO $pdo, int $userId, int $currentUserId): void
|
|
||||||
{
|
|
||||||
// Prevent self-deletion
|
|
||||||
if ($userId === $currentUserId) {
|
|
||||||
errorResponse('Nemůžete smazat svůj vlastní účet');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user for audit log
|
|
||||||
$stmt = $pdo->prepare('SELECT username FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$user = $stmt->fetch();
|
|
||||||
|
|
||||||
if (!$user) {
|
|
||||||
errorResponse('Uživatel nebyl nalezen', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete related records first (refresh tokens for JWT auth)
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
|
||||||
// Delete user
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM users WHERE id = ?');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
AuditLog::logDelete('user', $userId, ['username' => $user['username']], "Smazán uživatel '{$user['username']}'");
|
|
||||||
|
|
||||||
successResponse(null, 'Uživatel byl úspěšně smazán');
|
|
||||||
}
|
|
||||||
|
|||||||
392
api/config.php
392
api/config.php
@@ -1,60 +1,20 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOHA Automation - API Configuration
|
* BOHA Automation - API Configuration Bootstrap
|
||||||
*
|
*
|
||||||
* Database and application configuration
|
* Nacte helper funkce, env promenne a konstanty.
|
||||||
|
* Toto je jediny soubor, ktery API endpointy musi require_once.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
// Load .env file
|
require_once __DIR__ . '/includes/helpers.php';
|
||||||
function loadEnv(string $path): bool
|
require_once dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
{
|
|
||||||
if (!file_exists($path)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
||||||
foreach ($lines as $line) {
|
|
||||||
if (strpos(trim($line), '#') === 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = explode('=', $line, 2);
|
|
||||||
if (count($parts) !== 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = trim($parts[0]);
|
|
||||||
$value = trim($parts[1]);
|
|
||||||
|
|
||||||
// Remove quotes if present
|
|
||||||
if (
|
|
||||||
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
|
||||||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
|
||||||
) {
|
|
||||||
$value = substr($value, 1, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
$_ENV[$name] = $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load .env from api directory
|
|
||||||
loadEnv(__DIR__ . '/.env');
|
loadEnv(__DIR__ . '/.env');
|
||||||
|
|
||||||
// Helper function to get env value with default
|
require_once __DIR__ . '/includes/constants.php';
|
||||||
function env(string $key, mixed $default = null): mixed
|
|
||||||
{
|
|
||||||
return $_ENV[$key] ?? $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Environment
|
|
||||||
define('APP_ENV', env('APP_ENV', 'production'));
|
|
||||||
define('DEBUG_MODE', APP_ENV === 'local');
|
|
||||||
|
|
||||||
if (DEBUG_MODE) {
|
if (DEBUG_MODE) {
|
||||||
error_reporting(E_ALL);
|
error_reporting(E_ALL);
|
||||||
@@ -63,343 +23,3 @@ if (DEBUG_MODE) {
|
|||||||
error_reporting(0);
|
error_reporting(0);
|
||||||
ini_set('display_errors', 0);
|
ini_set('display_errors', 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database configuration
|
|
||||||
define('DB_HOST', env('DB_HOST', 'localhost'));
|
|
||||||
define('DB_NAME', env('DB_NAME', ''));
|
|
||||||
define('DB_USER', env('DB_USER', ''));
|
|
||||||
define('DB_PASS', env('DB_PASS', ''));
|
|
||||||
define('DB_CHARSET', 'utf8mb4');
|
|
||||||
|
|
||||||
// Security configuration
|
|
||||||
define('MAX_LOGIN_ATTEMPTS', 5);
|
|
||||||
define('LOCKOUT_MINUTES', 15);
|
|
||||||
define('BCRYPT_COST', 12);
|
|
||||||
|
|
||||||
// CORS - aktualizuj po nasazeni na subdomenu
|
|
||||||
define('CORS_ALLOWED_ORIGINS', [
|
|
||||||
'http://www.boha-automation.cz',
|
|
||||||
'https://www.boha-automation.cz',
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Paths
|
|
||||||
define('API_ROOT', __DIR__);
|
|
||||||
define('INCLUDES_PATH', API_ROOT . '/includes');
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
define('RATE_LIMIT_STORAGE_PATH', __DIR__ . '/rate_limits');
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get PDO database connection (singleton pattern)
|
|
||||||
*
|
|
||||||
* @return PDO Database connection instance
|
|
||||||
* @throws PDOException If connection fails
|
|
||||||
*/
|
|
||||||
function db(): PDO
|
|
||||||
{
|
|
||||||
static $pdo = null;
|
|
||||||
|
|
||||||
if ($pdo === null) {
|
|
||||||
$dsn = sprintf(
|
|
||||||
'mysql:host=%s;dbname=%s;charset=%s',
|
|
||||||
DB_HOST,
|
|
||||||
DB_NAME,
|
|
||||||
DB_CHARSET
|
|
||||||
);
|
|
||||||
|
|
||||||
$options = [
|
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
||||||
PDO::ATTR_EMULATE_PREPARES => false,
|
|
||||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
if (DEBUG_MODE) {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
error_log('Database connection failed: ' . $e->getMessage());
|
|
||||||
throw new PDOException('Database connection failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $pdo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set CORS headers for API responses
|
|
||||||
*/
|
|
||||||
function setCorsHeaders(): void
|
|
||||||
{
|
|
||||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
|
||||||
|
|
||||||
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
|
|
||||||
header("Access-Control-Allow-Origin: $origin");
|
|
||||||
header('Access-Control-Allow-Credentials: true');
|
|
||||||
} elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) {
|
|
||||||
header("Access-Control-Allow-Origin: $origin");
|
|
||||||
header('Access-Control-Allow-Credentials: true');
|
|
||||||
}
|
|
||||||
// Neznamy origin = zadny CORS header
|
|
||||||
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
|
||||||
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
|
||||||
header('Access-Control-Max-Age: 86400');
|
|
||||||
|
|
||||||
// Handle preflight requests
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
||||||
http_response_code(200);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send JSON response and exit
|
|
||||||
*
|
|
||||||
* @param mixed $data Data to send
|
|
||||||
* @param int $statusCode HTTP status code
|
|
||||||
*/
|
|
||||||
function jsonResponse($data, int $statusCode = 200): void
|
|
||||||
{
|
|
||||||
http_response_code($statusCode);
|
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
|
||||||
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
||||||
exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send error response
|
|
||||||
*
|
|
||||||
* @param string $message Error message
|
|
||||||
* @param int $statusCode HTTP status code
|
|
||||||
*/
|
|
||||||
function errorResponse(string $message, int $statusCode = 400): void
|
|
||||||
{
|
|
||||||
jsonResponse(['success' => false, 'error' => $message], $statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send success response
|
|
||||||
*
|
|
||||||
* @param mixed $data Data to include
|
|
||||||
* @param string $message Optional message
|
|
||||||
*/
|
|
||||||
function successResponse($data = null, string $message = ''): void
|
|
||||||
{
|
|
||||||
$response = ['success' => true];
|
|
||||||
if ($message) {
|
|
||||||
$response['message'] = $message;
|
|
||||||
}
|
|
||||||
if ($data !== null) {
|
|
||||||
$response['data'] = $data;
|
|
||||||
}
|
|
||||||
jsonResponse($response);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get JSON request body
|
|
||||||
*
|
|
||||||
* @return array<string, mixed> Decoded JSON data
|
|
||||||
*/
|
|
||||||
function getJsonInput(): array
|
|
||||||
{
|
|
||||||
$input = file_get_contents('php://input');
|
|
||||||
$data = json_decode($input, true);
|
|
||||||
return is_array($data) ? $data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sanitize string input
|
|
||||||
*
|
|
||||||
* @param string $input Input string
|
|
||||||
* @return string Sanitized string
|
|
||||||
*/
|
|
||||||
function sanitize(string $input): string
|
|
||||||
{
|
|
||||||
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate email format
|
|
||||||
*
|
|
||||||
* @param string $email Email to validate
|
|
||||||
* @return bool True if valid
|
|
||||||
*/
|
|
||||||
function isValidEmail(string $email): bool
|
|
||||||
{
|
|
||||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate and sanitize month parameter (YYYY-MM format)
|
|
||||||
*/
|
|
||||||
function validateMonth(string $param = 'month'): string
|
|
||||||
{
|
|
||||||
$month = $_GET[$param] ?? date('Y-m');
|
|
||||||
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
|
||||||
$month = date('Y-m');
|
|
||||||
}
|
|
||||||
return $month;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client IP address
|
|
||||||
*
|
|
||||||
* Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP).
|
|
||||||
* If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future,
|
|
||||||
* update this function to trust specific proxy headers only from known proxy IPs.
|
|
||||||
*
|
|
||||||
* @return string IP address
|
|
||||||
*/
|
|
||||||
function getClientIp(): string
|
|
||||||
{
|
|
||||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set security headers for API responses
|
|
||||||
*
|
|
||||||
* Sets standard security headers to protect against common web vulnerabilities:
|
|
||||||
* - X-Content-Type-Options: Prevents MIME type sniffing
|
|
||||||
* - X-Frame-Options: Prevents clickjacking attacks
|
|
||||||
* - X-XSS-Protection: Enables browser XSS filter
|
|
||||||
* - Referrer-Policy: Controls referrer information sent with requests
|
|
||||||
*
|
|
||||||
* Note: Content-Security-Policy is not set here as it may interfere with the React frontend
|
|
||||||
*/
|
|
||||||
function setSecurityHeaders(): void
|
|
||||||
{
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
header('X-Frame-Options: DENY');
|
|
||||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
|
||||||
if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
|
||||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set no-cache headers
|
|
||||||
*
|
|
||||||
* Prevents browser caching for sensitive endpoints
|
|
||||||
*/
|
|
||||||
function setNoCacheHeaders(): void
|
|
||||||
{
|
|
||||||
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
|
||||||
header('Cache-Control: post-check=0, pre-check=0', false);
|
|
||||||
header('Pragma: no-cache');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor)
|
|
||||||
*/
|
|
||||||
function generateSharedNumber(PDO $pdo): string
|
|
||||||
{
|
|
||||||
$yy = date('y');
|
|
||||||
|
|
||||||
$settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch();
|
|
||||||
$typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71';
|
|
||||||
|
|
||||||
$prefix = $yy . $typeCode;
|
|
||||||
$prefixLen = strlen($prefix);
|
|
||||||
$likePattern = $prefix . '%';
|
|
||||||
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT COALESCE(MAX(seq), 0) FROM (
|
|
||||||
SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq
|
|
||||||
FROM orders WHERE order_number LIKE ?
|
|
||||||
UNION ALL
|
|
||||||
SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq
|
|
||||||
FROM projects WHERE project_number LIKE ?
|
|
||||||
) combined
|
|
||||||
');
|
|
||||||
$stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]);
|
|
||||||
$max = (int) $stmt->fetchColumn();
|
|
||||||
|
|
||||||
return sprintf('%s%s%04d', $yy, $typeCode, $max + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get permissions for a user by their ID
|
|
||||||
* Cached per-request via static variable
|
|
||||||
*
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
function getUserPermissions(int $userId): array
|
|
||||||
{
|
|
||||||
static $cache = [];
|
|
||||||
|
|
||||||
if (isset($cache[$userId])) {
|
|
||||||
return $cache[$userId];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$pdo = db();
|
|
||||||
|
|
||||||
// Check if user has admin role (superuser bypass)
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT r.name FROM users u
|
|
||||||
JOIN roles r ON u.role_id = r.id
|
|
||||||
WHERE u.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$role = $stmt->fetch();
|
|
||||||
|
|
||||||
if ($role && $role['name'] === 'admin') {
|
|
||||||
// Admin gets all permissions
|
|
||||||
$stmt = $pdo->query('SELECT name FROM permissions');
|
|
||||||
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
||||||
return $cache[$userId];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular user: get permissions via role_permissions
|
|
||||||
$stmt = $pdo->prepare('
|
|
||||||
SELECT p.name
|
|
||||||
FROM permissions p
|
|
||||||
JOIN role_permissions rp ON p.id = rp.permission_id
|
|
||||||
JOIN users u ON u.role_id = rp.role_id
|
|
||||||
WHERE u.id = ?
|
|
||||||
');
|
|
||||||
$stmt->execute([$userId]);
|
|
||||||
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
|
||||||
return $cache[$userId];
|
|
||||||
} catch (PDOException $e) {
|
|
||||||
error_log('getUserPermissions error: ' . $e->getMessage());
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Require a specific permission, return 403 if denied
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function requirePermission(array $authData, string $permission): void
|
|
||||||
{
|
|
||||||
// Admin superuser bypass
|
|
||||||
if ($authData['user']['is_admin'] ?? false) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$permissions = getUserPermissions($authData['user_id']);
|
|
||||||
if (!in_array($permission, $permissions)) {
|
|
||||||
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has a specific permission (returns bool)
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $authData
|
|
||||||
*/
|
|
||||||
function hasPermission(array $authData, string $permission): bool
|
|
||||||
{
|
|
||||||
if ($authData['user']['is_admin'] ?? false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$permissions = getUserPermissions($authData['user_id']);
|
|
||||||
return in_array($permission, $permissions);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once dirname(__DIR__) . '/config.php';
|
|
||||||
|
|
||||||
class AuditLog
|
class AuditLog
|
||||||
{
|
{
|
||||||
// Action types
|
// Action types
|
||||||
|
|||||||
@@ -13,9 +13,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
|
||||||
require_once dirname(__DIR__) . '/config.php';
|
|
||||||
|
|
||||||
use Firebase\JWT\JWT;
|
use Firebase\JWT\JWT;
|
||||||
use Firebase\JWT\Key;
|
use Firebase\JWT\Key;
|
||||||
use Firebase\JWT\ExpiredException;
|
use Firebase\JWT\ExpiredException;
|
||||||
|
|||||||
@@ -8,8 +8,6 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require_once __DIR__ . '/Mailer.php';
|
|
||||||
|
|
||||||
class LeaveNotification
|
class LeaveNotification
|
||||||
{
|
{
|
||||||
/** @var array<string, string> */
|
/** @var array<string, string> */
|
||||||
|
|||||||
39
api/includes/constants.php
Normal file
39
api/includes/constants.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aplikacni konstanty
|
||||||
|
*
|
||||||
|
* Definuje konstanty pouzivane v celé API.
|
||||||
|
* Vyzaduje, aby byl pred includovanim tohoto souboru nacten helpers.php a .env.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Environment
|
||||||
|
define('APP_ENV', env('APP_ENV', 'production'));
|
||||||
|
define('DEBUG_MODE', APP_ENV === 'local');
|
||||||
|
|
||||||
|
// Database configuration
|
||||||
|
define('DB_HOST', env('DB_HOST', 'localhost'));
|
||||||
|
define('DB_NAME', env('DB_NAME', ''));
|
||||||
|
define('DB_USER', env('DB_USER', ''));
|
||||||
|
define('DB_PASS', env('DB_PASS', ''));
|
||||||
|
define('DB_CHARSET', 'utf8mb4');
|
||||||
|
|
||||||
|
// Security configuration
|
||||||
|
define('MAX_LOGIN_ATTEMPTS', 5);
|
||||||
|
define('LOCKOUT_MINUTES', 15);
|
||||||
|
define('BCRYPT_COST', 12);
|
||||||
|
|
||||||
|
// CORS - aktualizuj po nasazeni na subdomenu
|
||||||
|
define('CORS_ALLOWED_ORIGINS', [
|
||||||
|
'http://www.boha-automation.cz',
|
||||||
|
'https://www.boha-automation.cz',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Paths
|
||||||
|
define('API_ROOT', dirname(__DIR__));
|
||||||
|
define('INCLUDES_PATH', API_ROOT . '/includes');
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');
|
||||||
357
api/includes/helpers.php
Normal file
357
api/includes/helpers.php
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sdilene helper funkce pro API
|
||||||
|
*
|
||||||
|
* Definuje pomocne funkce pouzivane v celé API.
|
||||||
|
* Tento soubor NEDELA zadne side effects - jen definuje funkce.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
function loadEnv(string $path): bool
|
||||||
|
{
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (strpos(trim($line), '#') === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('=', $line, 2);
|
||||||
|
if (count($parts) !== 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim($parts[0]);
|
||||||
|
$value = trim($parts[1]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
|
||||||
|
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
|
||||||
|
) {
|
||||||
|
$value = substr($value, 1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_ENV[$name] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function env(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
return $_ENV[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get PDO database connection (singleton pattern)
|
||||||
|
*
|
||||||
|
* @return PDO Database connection instance
|
||||||
|
* @throws PDOException If connection fails
|
||||||
|
*/
|
||||||
|
function db(): PDO
|
||||||
|
{
|
||||||
|
static $pdo = null;
|
||||||
|
|
||||||
|
if ($pdo === null) {
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;dbname=%s;charset=%s',
|
||||||
|
DB_HOST,
|
||||||
|
DB_NAME,
|
||||||
|
DB_CHARSET
|
||||||
|
);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
error_log('Database connection failed: ' . $e->getMessage());
|
||||||
|
throw new PDOException('Database connection failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set CORS headers for API responses
|
||||||
|
*/
|
||||||
|
function setCorsHeaders(): void
|
||||||
|
{
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
|
||||||
|
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
|
||||||
|
header("Access-Control-Allow-Origin: $origin");
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
} elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) {
|
||||||
|
header("Access-Control-Allow-Origin: $origin");
|
||||||
|
header('Access-Control-Allow-Credentials: true');
|
||||||
|
}
|
||||||
|
// Neznamy origin = zadny CORS header
|
||||||
|
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
|
||||||
|
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
|
||||||
|
header('Access-Control-Max-Age: 86400');
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send JSON response and exit
|
||||||
|
*
|
||||||
|
* @param mixed $data Data to send
|
||||||
|
* @param int $statusCode HTTP status code
|
||||||
|
*/
|
||||||
|
function jsonResponse($data, int $statusCode = 200): void
|
||||||
|
{
|
||||||
|
http_response_code($statusCode);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send error response
|
||||||
|
*
|
||||||
|
* @param string $message Error message
|
||||||
|
* @param int $statusCode HTTP status code
|
||||||
|
*/
|
||||||
|
function errorResponse(string $message, int $statusCode = 400): void
|
||||||
|
{
|
||||||
|
jsonResponse(['success' => false, 'error' => $message], $statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send success response
|
||||||
|
*
|
||||||
|
* @param mixed $data Data to include
|
||||||
|
* @param string $message Optional message
|
||||||
|
*/
|
||||||
|
function successResponse($data = null, string $message = ''): void
|
||||||
|
{
|
||||||
|
$response = ['success' => true];
|
||||||
|
if ($message) {
|
||||||
|
$response['message'] = $message;
|
||||||
|
}
|
||||||
|
if ($data !== null) {
|
||||||
|
$response['data'] = $data;
|
||||||
|
}
|
||||||
|
jsonResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get JSON request body
|
||||||
|
*
|
||||||
|
* @return array<string, mixed> Decoded JSON data
|
||||||
|
*/
|
||||||
|
function getJsonInput(): array
|
||||||
|
{
|
||||||
|
$input = file_get_contents('php://input');
|
||||||
|
$data = json_decode($input, true);
|
||||||
|
return is_array($data) ? $data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize string input
|
||||||
|
*
|
||||||
|
* @param string $input Input string
|
||||||
|
* @return string Sanitized string
|
||||||
|
*/
|
||||||
|
function sanitize(string $input): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email format
|
||||||
|
*
|
||||||
|
* @param string $email Email to validate
|
||||||
|
* @return bool True if valid
|
||||||
|
*/
|
||||||
|
function isValidEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and sanitize month parameter (YYYY-MM format)
|
||||||
|
*/
|
||||||
|
function validateMonth(string $param = 'month'): string
|
||||||
|
{
|
||||||
|
$month = $_GET[$param] ?? date('Y-m');
|
||||||
|
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
|
||||||
|
$month = date('Y-m');
|
||||||
|
}
|
||||||
|
return $month;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address
|
||||||
|
*
|
||||||
|
* Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP).
|
||||||
|
* If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future,
|
||||||
|
* update this function to trust specific proxy headers only from known proxy IPs.
|
||||||
|
*
|
||||||
|
* @return string IP address
|
||||||
|
*/
|
||||||
|
function getClientIp(): string
|
||||||
|
{
|
||||||
|
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set security headers for API responses
|
||||||
|
*
|
||||||
|
* Sets standard security headers to protect against common web vulnerabilities:
|
||||||
|
* - X-Content-Type-Options: Prevents MIME type sniffing
|
||||||
|
* - X-Frame-Options: Prevents clickjacking attacks
|
||||||
|
* - X-XSS-Protection: Enables browser XSS filter
|
||||||
|
* - Referrer-Policy: Controls referrer information sent with requests
|
||||||
|
*
|
||||||
|
* Note: Content-Security-Policy is not set here as it may interfere with the React frontend
|
||||||
|
*/
|
||||||
|
function setSecurityHeaders(): void
|
||||||
|
{
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('X-Frame-Options: DENY');
|
||||||
|
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||||
|
if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
|
||||||
|
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set no-cache headers
|
||||||
|
*
|
||||||
|
* Prevents browser caching for sensitive endpoints
|
||||||
|
*/
|
||||||
|
function setNoCacheHeaders(): void
|
||||||
|
{
|
||||||
|
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
|
||||||
|
header('Cache-Control: post-check=0, pre-check=0', false);
|
||||||
|
header('Pragma: no-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor)
|
||||||
|
*/
|
||||||
|
function generateSharedNumber(PDO $pdo): string
|
||||||
|
{
|
||||||
|
$yy = date('y');
|
||||||
|
|
||||||
|
$settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch();
|
||||||
|
$typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71';
|
||||||
|
|
||||||
|
$prefix = $yy . $typeCode;
|
||||||
|
$prefixLen = strlen($prefix);
|
||||||
|
$likePattern = $prefix . '%';
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT COALESCE(MAX(seq), 0) FROM (
|
||||||
|
SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq
|
||||||
|
FROM orders WHERE order_number LIKE ?
|
||||||
|
UNION ALL
|
||||||
|
SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq
|
||||||
|
FROM projects WHERE project_number LIKE ?
|
||||||
|
) combined
|
||||||
|
');
|
||||||
|
$stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]);
|
||||||
|
$max = (int) $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return sprintf('%s%s%04d', $yy, $typeCode, $max + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permissions for a user by their ID
|
||||||
|
* Cached per-request via static variable
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
function getUserPermissions(int $userId): array
|
||||||
|
{
|
||||||
|
static $cache = [];
|
||||||
|
|
||||||
|
if (isset($cache[$userId])) {
|
||||||
|
return $cache[$userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT r.name FROM users u
|
||||||
|
JOIN roles r ON u.role_id = r.id
|
||||||
|
WHERE u.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$role = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($role && $role['name'] === 'admin') {
|
||||||
|
$stmt = $pdo->query('SELECT name FROM permissions');
|
||||||
|
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
return $cache[$userId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('
|
||||||
|
SELECT p.name
|
||||||
|
FROM permissions p
|
||||||
|
JOIN role_permissions rp ON p.id = rp.permission_id
|
||||||
|
JOIN users u ON u.role_id = rp.role_id
|
||||||
|
WHERE u.id = ?
|
||||||
|
');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
return $cache[$userId];
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
error_log('getUserPermissions error: ' . $e->getMessage());
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require a specific permission, return 403 if denied
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function requirePermission(array $authData, string $permission): void
|
||||||
|
{
|
||||||
|
if ($authData['user']['is_admin'] ?? false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = getUserPermissions($authData['user_id']);
|
||||||
|
if (!in_array($permission, $permissions)) {
|
||||||
|
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has a specific permission (returns bool)
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $authData
|
||||||
|
*/
|
||||||
|
function hasPermission(array $authData, string $permission): bool
|
||||||
|
{
|
||||||
|
if ($authData['user']['is_admin'] ?? false) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$permissions = getUserPermissions($authData['user_id']);
|
||||||
|
return in_array($permission, $permissions);
|
||||||
|
}
|
||||||
@@ -4,3 +4,12 @@ parameters:
|
|||||||
- api
|
- api
|
||||||
excludePaths:
|
excludePaths:
|
||||||
- vendor
|
- vendor
|
||||||
|
bootstrapFiles:
|
||||||
|
- vendor/autoload.php
|
||||||
|
- api/includes/helpers.php
|
||||||
|
- api/includes/constants.php
|
||||||
|
scanDirectories:
|
||||||
|
- api/admin/handlers
|
||||||
|
dynamicConstantNames:
|
||||||
|
- DEBUG_MODE
|
||||||
|
- APP_ENV
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ import { defineConfig } from 'vite'
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true,
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:80',
|
target: 'http://localhost:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: 'localhost',
|
cookieDomainRewrite: 'localhost',
|
||||||
cookiePathRewrite: '/'
|
cookiePathRewrite: '/'
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
Reference in New Issue
Block a user