Initial commit

This commit is contained in:
2026-03-12 12:43:56 +01:00
commit f733dee856
137 changed files with 51192 additions and 0 deletions

533
api/admin/projects.php Normal file
View File

@@ -0,0 +1,533 @@
<?php
/**
* BOHA Automation - Projects API
*
* GET /api/admin/projects.php - List projects
* GET /api/admin/projects.php?action=detail&id=X - Get project detail
* GET /api/admin/projects.php?action=notes&id=X - Get project notes
* GET /api/admin/projects.php?action=next_number - Get next available project number
* POST /api/admin/projects.php - Create new project (manual)
* POST /api/admin/projects.php?action=add_note&id=X - Add note to project
* PUT /api/admin/projects.php?id=X - Update project
* DELETE /api/admin/projects.php?action=delete_note&noteId=X - Delete note (admin)
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/config.php';
require_once dirname(__DIR__) . '/includes/JWTAuth.php';
require_once dirname(__DIR__) . '/includes/AuditLog.php';
setCorsHeaders();
setSecurityHeaders();
setNoCacheHeaders();
header('Content-Type: application/json; charset=utf-8');
$authData = JWTAuth::requireAuth();
AuditLog::setUser($authData['user_id'], $authData['user']['username'] ?? 'unknown');
$method = $_SERVER['REQUEST_METHOD'];
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int) $_GET['id'] : null;
try {
$pdo = db();
switch ($method) {
case 'GET':
requirePermission($authData, 'projects.view');
switch ($action) {
case 'detail':
if (!$id) {
errorResponse('ID projektu je povinné');
}
handleGetDetail($pdo, $id);
break;
case 'notes':
if (!$id) {
errorResponse('ID projektu je povinné');
}
handleGetNotes($pdo, $id);
break;
case 'next_number':
requirePermission($authData, 'projects.create');
handleGetNextNumber($pdo);
break;
default:
handleGetList($pdo);
}
break;
case 'POST':
if ($action === 'add_note') {
requirePermission($authData, 'projects.view');
if (!$id) {
errorResponse('ID projektu je povinné');
}
handleAddNote($pdo, $id, $authData);
} elseif (!$action) {
requirePermission($authData, 'projects.create');
handleCreateProject($pdo);
} else {
errorResponse('Neznámá akce', 400);
}
break;
case 'PUT':
requirePermission($authData, 'projects.edit');
if (!$id) {
errorResponse('ID projektu je povinné');
}
handleUpdateProject($pdo, $id);
break;
case 'DELETE':
if ($action === 'delete_note') {
requirePermission($authData, 'projects.edit');
$noteId = isset($_GET['noteId']) ? (int) $_GET['noteId'] : null;
if (!$noteId) {
errorResponse('ID poznámky je povinné');
}
handleDeleteNote($pdo, $noteId, $authData);
} elseif (!$action && $id) {
requirePermission($authData, 'projects.delete');
handleDeleteProject($pdo, $id);
} else {
errorResponse('Neznámá akce', 400);
}
break;
default:
errorResponse('Metoda není povolena', 405);
}
} catch (PDOException $e) {
error_log('Projects API error: ' . $e->getMessage());
if (DEBUG_MODE) {
errorResponse('Chyba databáze: ' . $e->getMessage(), 500);
} else {
errorResponse('Chyba databáze', 500);
}
}
// --- 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');
}