Initial commit
This commit is contained in:
533
api/admin/projects.php
Normal file
533
api/admin/projects.php
Normal 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¬eId=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');
|
||||
}
|
||||
Reference in New Issue
Block a user