feat: zodpovedna osoba za projekt - novy sloupec + editace

Pridano pole responsible_user_id do tabulky projects s FK na users.
Select zodpovedne osoby v ProjectDetail, ProjectCreate a sloupec v seznamu projektu.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 12:03:44 +01:00
parent 308941449e
commit 9e3c95e576
44 changed files with 203 additions and 62 deletions

View File

@@ -7,6 +7,16 @@ function generateProjectNumber(PDO $pdo): string
return generateSharedNumber($pdo); return generateSharedNumber($pdo);
} }
function handleGetUsers(PDO $pdo): void
{
$stmt = $pdo->query(
"SELECT id, CONCAT(first_name, ' ', last_name) as name
FROM users WHERE is_active = 1 ORDER BY first_name, last_name"
);
$users = $stmt->fetchAll();
successResponse(['users' => $users]);
}
function handleGetNextNumber(PDO $pdo): void function handleGetNextNumber(PDO $pdo): void
{ {
$number = generateProjectNumber($pdo); $number = generateProjectNumber($pdo);
@@ -37,6 +47,15 @@ function handleCreateProject(PDO $pdo): void
errorResponse('Zákazník nebyl nalezen', 404); errorResponse('Zákazník nebyl nalezen', 404);
} }
$responsibleUserId = isset($input['responsible_user_id']) ? (int)$input['responsible_user_id'] : null;
if ($responsibleUserId) {
$stmt = $pdo->prepare('SELECT id FROM users WHERE id = ?');
$stmt->execute([$responsibleUserId]);
if (!$stmt->fetch()) {
errorResponse('Zodpovědná osoba nebyla nalezena', 404);
}
}
$startDate = $input['start_date'] ?? date('Y-m-d'); $startDate = $input['start_date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
errorResponse('Neplatný formát data zahájení'); errorResponse('Neplatný formát data zahájení');
@@ -79,14 +98,15 @@ function handleCreateProject(PDO $pdo): void
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO projects ( INSERT INTO projects (
project_number, name, customer_id, project_number, name, customer_id, responsible_user_id,
status, start_date, created_at, modified_at status, start_date, created_at, modified_at
) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW()) ) VALUES (?, ?, ?, ?, 'aktivni', ?, NOW(), NOW())
"); ");
$stmt->execute([ $stmt->execute([
$projectNumber, $projectNumber,
$name, $name,
$customerId, $customerId,
$responsibleUserId,
$startDate, $startDate,
]); ]);
$projectId = (int)$pdo->lastInsertId(); $projectId = (int)$pdo->lastInsertId();
@@ -185,15 +205,17 @@ function handleGetList(PDO $pdo): void
$from = "FROM projects p $from = "FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id"; LEFT JOIN orders o ON p.order_id = o.id
LEFT JOIN users u ON p.responsible_user_id = u.id";
$result = PaginationHelper::paginate( $result = PaginationHelper::paginate(
$pdo, $pdo,
"SELECT COUNT(*) {$from} {$where}", "SELECT COUNT(*) {$from} {$where}",
"SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, "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, p.order_id, p.quotation_id, p.created_at, p.responsible_user_id,
c.name as customer_name, c.name as customer_name,
o.order_number o.order_number,
CONCAT(u.first_name, ' ', u.last_name) as responsible_user_name
{$from} {$where} {$from} {$where}
ORDER BY {$p['sort']} {$p['order']}", ORDER BY {$p['sort']} {$p['order']}",
$params, $params,
@@ -212,14 +234,17 @@ function handleGetDetail(PDO $pdo, int $id): void
SELECT p.id, p.project_number, p.name, p.customer_id, SELECT p.id, p.project_number, p.name, p.customer_id,
p.quotation_id, p.order_id, p.status, p.quotation_id, p.order_id, p.status,
p.start_date, p.end_date, p.notes, p.start_date, p.end_date, p.notes,
p.responsible_user_id,
p.created_at, p.modified_at, p.created_at, p.modified_at,
c.name as customer_name, c.name as customer_name,
o.order_number, o.status as order_status, o.order_number, o.status as order_status,
q.quotation_number q.quotation_number,
CONCAT(u.first_name, \' \', u.last_name) as responsible_user_name
FROM projects p FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id LEFT JOIN orders o ON p.order_id = o.id
LEFT JOIN quotations q ON p.quotation_id = q.id LEFT JOIN quotations q ON p.quotation_id = q.id
LEFT JOIN users u ON p.responsible_user_id = u.id
WHERE p.id = ? WHERE p.id = ?
'); ');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -235,7 +260,7 @@ function handleGetDetail(PDO $pdo, int $id): void
function handleUpdateProject(PDO $pdo, int $id): void function handleUpdateProject(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, project_number, name, status, start_date, end_date, notes 'SELECT id, project_number, name, status, start_date, end_date, notes, responsible_user_id
FROM projects WHERE id = ?' FROM projects WHERE id = ?'
); );
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -282,6 +307,18 @@ function handleUpdateProject(PDO $pdo, int $id): void
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
} }
// Zodpovedna osoba
$responsibleUserId = array_key_exists('responsible_user_id', $input)
? ($input['responsible_user_id'] ? (int)$input['responsible_user_id'] : null)
: $project['responsible_user_id'];
if ($responsibleUserId) {
$stmt = $pdo->prepare('SELECT id FROM users WHERE id = ?');
$stmt->execute([$responsibleUserId]);
if (!$stmt->fetch()) {
errorResponse('Zodpovědná osoba nebyla nalezena', 404);
}
}
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
@@ -291,6 +328,7 @@ function handleUpdateProject(PDO $pdo, int $id): void
start_date = ?, start_date = ?,
end_date = ?, end_date = ?,
notes = ?, notes = ?,
responsible_user_id = ?,
modified_at = NOW() modified_at = NOW()
WHERE id = ? WHERE id = ?
'); ');
@@ -300,6 +338,7 @@ function handleUpdateProject(PDO $pdo, int $id): void
$input['start_date'] ?? $project['start_date'], $input['start_date'] ?? $project['start_date'],
$input['end_date'] ?? $project['end_date'], $input['end_date'] ?? $project['end_date'],
$notes, $notes,
$responsibleUserId,
$id, $id,
]); ]);

View File

@@ -56,6 +56,9 @@ try {
requirePermission($authData, 'projects.create'); requirePermission($authData, 'projects.create');
handleGetNextNumber($pdo); handleGetNextNumber($pdo);
break; break;
case 'users':
handleGetUsers($pdo);
break;
default: default:
handleGetList($pdo); handleGetList($pdo);
} }

View File

@@ -7,6 +7,16 @@ function generateProjectNumber(PDO $pdo): string
return generateSharedNumber($pdo); return generateSharedNumber($pdo);
} }
function handleGetUsers(PDO $pdo): void
{
$stmt = $pdo->query(
"SELECT id, CONCAT(first_name, ' ', last_name) as name
FROM users WHERE is_active = 1 ORDER BY first_name, last_name"
);
$users = $stmt->fetchAll();
successResponse(['users' => $users]);
}
function handleGetNextNumber(PDO $pdo): void function handleGetNextNumber(PDO $pdo): void
{ {
$number = generateProjectNumber($pdo); $number = generateProjectNumber($pdo);
@@ -37,6 +47,15 @@ function handleCreateProject(PDO $pdo): void
errorResponse('Zákazník nebyl nalezen', 404); errorResponse('Zákazník nebyl nalezen', 404);
} }
$responsibleUserId = isset($input['responsible_user_id']) ? (int)$input['responsible_user_id'] : null;
if ($responsibleUserId) {
$stmt = $pdo->prepare('SELECT id FROM users WHERE id = ?');
$stmt->execute([$responsibleUserId]);
if (!$stmt->fetch()) {
errorResponse('Zodpovědná osoba nebyla nalezena', 404);
}
}
$startDate = $input['start_date'] ?? date('Y-m-d'); $startDate = $input['start_date'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
errorResponse('Neplatný formát data zahájení'); errorResponse('Neplatný formát data zahájení');
@@ -79,14 +98,15 @@ function handleCreateProject(PDO $pdo): void
$stmt = $pdo->prepare(" $stmt = $pdo->prepare("
INSERT INTO projects ( INSERT INTO projects (
project_number, name, customer_id, project_number, name, customer_id, responsible_user_id,
status, start_date, created_at, modified_at status, start_date, created_at, modified_at
) VALUES (?, ?, ?, 'aktivni', ?, NOW(), NOW()) ) VALUES (?, ?, ?, ?, 'aktivni', ?, NOW(), NOW())
"); ");
$stmt->execute([ $stmt->execute([
$projectNumber, $projectNumber,
$name, $name,
$customerId, $customerId,
$responsibleUserId,
$startDate, $startDate,
]); ]);
$projectId = (int)$pdo->lastInsertId(); $projectId = (int)$pdo->lastInsertId();
@@ -185,15 +205,17 @@ function handleGetList(PDO $pdo): void
$from = "FROM projects p $from = "FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id"; LEFT JOIN orders o ON p.order_id = o.id
LEFT JOIN users u ON p.responsible_user_id = u.id";
$result = PaginationHelper::paginate( $result = PaginationHelper::paginate(
$pdo, $pdo,
"SELECT COUNT(*) {$from} {$where}", "SELECT COUNT(*) {$from} {$where}",
"SELECT p.id, p.project_number, p.name, p.status, p.start_date, p.end_date, "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, p.order_id, p.quotation_id, p.created_at, p.responsible_user_id,
c.name as customer_name, c.name as customer_name,
o.order_number o.order_number,
CONCAT(u.first_name, ' ', u.last_name) as responsible_user_name
{$from} {$where} {$from} {$where}
ORDER BY {$p['sort']} {$p['order']}", ORDER BY {$p['sort']} {$p['order']}",
$params, $params,
@@ -212,14 +234,17 @@ function handleGetDetail(PDO $pdo, int $id): void
SELECT p.id, p.project_number, p.name, p.customer_id, SELECT p.id, p.project_number, p.name, p.customer_id,
p.quotation_id, p.order_id, p.status, p.quotation_id, p.order_id, p.status,
p.start_date, p.end_date, p.notes, p.start_date, p.end_date, p.notes,
p.responsible_user_id,
p.created_at, p.modified_at, p.created_at, p.modified_at,
c.name as customer_name, c.name as customer_name,
o.order_number, o.status as order_status, o.order_number, o.status as order_status,
q.quotation_number q.quotation_number,
CONCAT(u.first_name, \' \', u.last_name) as responsible_user_name
FROM projects p FROM projects p
LEFT JOIN customers c ON p.customer_id = c.id LEFT JOIN customers c ON p.customer_id = c.id
LEFT JOIN orders o ON p.order_id = o.id LEFT JOIN orders o ON p.order_id = o.id
LEFT JOIN quotations q ON p.quotation_id = q.id LEFT JOIN quotations q ON p.quotation_id = q.id
LEFT JOIN users u ON p.responsible_user_id = u.id
WHERE p.id = ? WHERE p.id = ?
'); ');
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -235,7 +260,7 @@ function handleGetDetail(PDO $pdo, int $id): void
function handleUpdateProject(PDO $pdo, int $id): void function handleUpdateProject(PDO $pdo, int $id): void
{ {
$stmt = $pdo->prepare( $stmt = $pdo->prepare(
'SELECT id, project_number, name, status, start_date, end_date, notes 'SELECT id, project_number, name, status, start_date, end_date, notes, responsible_user_id
FROM projects WHERE id = ?' FROM projects WHERE id = ?'
); );
$stmt->execute([$id]); $stmt->execute([$id]);
@@ -282,6 +307,18 @@ function handleUpdateProject(PDO $pdo, int $id): void
errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)'); errorResponse('Poznámky jsou příliš dlouhé (max 5000 znaků)');
} }
// Zodpovedna osoba
$responsibleUserId = array_key_exists('responsible_user_id', $input)
? ($input['responsible_user_id'] ? (int)$input['responsible_user_id'] : null)
: $project['responsible_user_id'];
if ($responsibleUserId) {
$stmt = $pdo->prepare('SELECT id FROM users WHERE id = ?');
$stmt->execute([$responsibleUserId]);
if (!$stmt->fetch()) {
errorResponse('Zodpovědná osoba nebyla nalezena', 404);
}
}
$pdo->beginTransaction(); $pdo->beginTransaction();
try { try {
$stmt = $pdo->prepare(' $stmt = $pdo->prepare('
@@ -291,6 +328,7 @@ function handleUpdateProject(PDO $pdo, int $id): void
start_date = ?, start_date = ?,
end_date = ?, end_date = ?,
notes = ?, notes = ?,
responsible_user_id = ?,
modified_at = NOW() modified_at = NOW()
WHERE id = ? WHERE id = ?
'); ');
@@ -300,6 +338,7 @@ function handleUpdateProject(PDO $pdo, int $id): void
$input['start_date'] ?? $project['start_date'], $input['start_date'] ?? $project['start_date'],
$input['end_date'] ?? $project['end_date'], $input['end_date'] ?? $project['end_date'],
$notes, $notes,
$responsibleUserId,
$id, $id,
]); ]);

View File

@@ -56,6 +56,9 @@ try {
requirePermission($authData, 'projects.create'); requirePermission($authData, 'projects.create');
handleGetNextNumber($pdo); handleGetNextNumber($pdo);
break; break;
case 'users':
handleGetUsers($pdo);
break;
default: default:
handleGetList($pdo); handleGetList($pdo);
} }

View File

@@ -1 +1 @@
{"window_start":1773397693,"count":7} {"window_start":1773397817,"count":1}

View File

@@ -1 +1 @@
{"window_start":1773396919,"count":1} {"window_start":1773399442,"count":1}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-CnEy_BDh.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(` import{j as e,m as f}from"./vendor-animation-0s3FMHwK.js";import{r as m}from"./vendor-react-BVs3cwbi.js";import{a9 as T}from"./vendor-utils-Dyr8OjFr.js";import{a as C,u as A,c as O,F as B,A as H}from"./index-CNxd7jIT.js";import{F as I}from"./Forbidden-D25jV3Oq.js";import{c as W,b as k,g as w,d as z,e as S,a as v,h as E,i as y,f as b}from"./attendanceHelpers-D6sLEw0q.js";const L="/api/admin",R=s=>s.break_start&&s.break_end?`${b(s.break_start)} - ${b(s.break_end)}`:s.break_start?`${b(s.break_start)} - ?`:"—",Z=s=>s.project_logs&&s.project_logs.length>0?e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:"0.125rem"},children:s.project_logs.map((n,g)=>{let d,c,o=!1;if(n.hours!==null&&n.hours!==void 0)d=parseInt(n.hours)||0,c=parseInt(n.minutes)||0;else{o=!n.ended_at;const x=n.ended_at?new Date(n.ended_at):new Date,p=Math.floor((x-new Date(n.started_at))/6e4);d=Math.floor(p/60),c=p%60}return e.jsxs("span",{className:"admin-badge",style:{fontSize:"0.7rem",display:"inline-block",background:o?"var(--accent-light)":void 0},children:[n.project_name||`#${n.project_id}`," (",d,":",String(c).padStart(2,"0"),"h",o?" ▸":"",")"]},n.id||g)})}):s.project_name?e.jsx("span",{className:"admin-badge admin-badge-wrap",style:{fontSize:"0.75rem"},children:s.project_name}):"—",Y=s=>s.overtime>0?e.jsxs("span",{className:"leave-badge badge-overtime",children:["+",s.overtime,"h přesčas"]}):s.remaining>0?e.jsxs("span",{style:{color:"#dc2626"},children:["",s.remaining,"h"]}):e.jsx("span",{style:{color:"#16a34a"},children:"splněno"});function Q(){const s=C(),{user:n,hasPermission:g}=A(),[d,c]=m.useState(!0),o=m.useRef(null),[x,p]=m.useState(()=>{const a=new Date;return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}`}),[t,D]=m.useState({records:[],month_name:"",year:new Date().getFullYear(),total_minutes:0,vacation_hours:0,sick_hours:0,holiday_hours:0,unpaid_hours:0,leave_balance:null,monthly_fund:null}),_=m.useCallback(async()=>{c(!0);try{const a=await O(`${L}/attendance.php?action=history&month=${x}`);if(a.status===401)return;const i=await a.json();i.success&&D(i.data)}catch{s.error("Nepodařilo se načíst data")}finally{c(!1)}},[x,s]);if(m.useEffect(()=>{_()},[_]),!g("attendance.history"))return e.jsx(I,{});const $=()=>{if(!o.current)return;const a=window.open("","_blank");a.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/ProjectCreate-FGcLP7J1.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/ProjectDetail-Dg0G_KTk.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-CnEy_BDh.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(` import{j as e,m as p,A as Z}from"./vendor-animation-0s3FMHwK.js";import{r as i,L as J}from"./vendor-react-BVs3cwbi.js";import{a9 as G}from"./vendor-utils-Dyr8OjFr.js";import{a as q,u as Q,c as b,b as X,F as r,A as C,f as l,C as ee}from"./index-CNxd7jIT.js";import{F as se}from"./Forbidden-D25jV3Oq.js";import{b as $}from"./attendanceHelpers-D6sLEw0q.js";const N="/api/admin";function de(){const d=q(),{hasPermission:L}=Q(),[k,D]=i.useState(!0),[j,V]=i.useState(()=>{const s=new Date;return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-01`}),[g,A]=i.useState(()=>{const s=new Date,t=new Date(s.getFullYear(),s.getMonth()+1,0).getDate();return`${s.getFullYear()}-${String(s.getMonth()+1).padStart(2,"0")}-${String(t).padStart(2,"0")}`}),[m,F]=i.useState(""),[h,E]=i.useState(""),[P,B]=i.useState({trips:[],vehicles:[],users:[],totals:{total:0,business:0,count:0}}),[n,I]=i.useState(null),w=i.useRef(null),[T,v]=i.useState(!1),[_,U]=i.useState(null),[a,o]=i.useState({vehicle_id:"",trip_date:"",start_km:"",end_km:"",route_from:"",route_to:"",is_business:1,notes:""}),[u,z]=i.useState({show:!1,trip:null}),y=i.useCallback(async(s=!0)=>{s&&D(!0);try{let t=`${N}/trips.php?action=admin&date_from=${j}&date_to=${g}`;m&&(t+=`&vehicle_id=${m}`),h&&(t+=`&user_id=${h}`);const c=await(await b(t)).json();c.success&&B(c.data)}catch{d.error("Nepodařilo se načíst data")}finally{s&&D(!1)}},[j,g,m,h,d]);if(i.useEffect(()=>{y()},[y]),X(T),!L("trips.admin"))return e.jsx(se,{});const H=s=>{U(s),o({vehicle_id:s.vehicle_id,trip_date:s.trip_date,start_km:s.start_km,end_km:s.end_km,route_from:s.route_from,route_to:s.route_to,is_business:s.is_business,notes:s.notes||""}),v(!0)},O=async()=>{if(parseInt(a.end_km)<=parseInt(a.start_km)){d.error("Konečný stav km musí být větší než počáteční");return}try{const t=await(await b(`${N}/trips.php?id=${_.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();t.success?(v(!1),await y(!1),await new Promise(x=>setTimeout(x,300)),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},W=async()=>{if(u.trip)try{const t=await(await b(`${N}/trips.php?id=${u.trip.id}`,{method:"DELETE"})).json();t.success?(z({show:!1,trip:null}),await y(!1),d.success(t.message)):d.error(t.error)}catch{d.error("Chyba připojení")}},K=async()=>{try{let s=`${N}/trips.php?action=print&date_from=${j}&date_to=${g}`;m&&(s+=`&vehicle_id=${m}`),h&&(s+=`&user_id=${h}`);const x=await(await b(s)).json();x.success&&(I(x.data),setTimeout(()=>{if(w.current){const c=window.open("","_blank");c.document.write(`
<!DOCTYPE html> <!DOCTYPE html>
<html lang="cs"> <html lang="cs">
<head> <head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-CnEy_BDh.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u}; import{j as x}from"./vendor-animation-0s3FMHwK.js";import{r as t}from"./vendor-react-BVs3cwbi.js";import{a as L,c as O}from"./index-CNxd7jIT.js";function J({column:e,sort:r,order:n}){return r!==e?null:x.jsx("svg",{width:"12",height:"12",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",style:{marginLeft:4,verticalAlign:"middle"},children:x.jsx("path",{d:n==="ASC"?"M18 15l-6-6-6 6":"M6 9l6 6 6-6"})})}function V(e,r="DESC"){const[n,a]=t.useState(e),[o,c]=t.useState(r),i=t.useRef(!1),S=t.useCallback(u=>{i.current=!0,a(m=>m===u?(c(h=>h==="ASC"?"DESC":"ASC"),m):(c("DESC"),u))},[]),d=i.current?n:null;return{sort:n,order:o,handleSort:S,activeSort:d}}function I(e,r=300){const[n,a]=t.useState(e);return t.useEffect(()=>{const o=setTimeout(()=>a(e),r);return()=>clearTimeout(o)},[e,r]),n}const N="/api/admin";function _(e,{dataKey:r,search:n,sort:a,order:o,page:c,perPage:i,extraParams:S,errorMsg:d="Nepodařilo se načíst data"}={}){const u=L(),[m,h]=t.useState([]),[j,D]=t.useState(!0),[w,k]=t.useState(null),l=t.useRef(null),p=S?JSON.stringify(S):"",b=I(n,300),C=t.useCallback(async()=>{l.current&&l.current.abort();const g=new AbortController;l.current=g;try{const s=new URLSearchParams;if(b&&s.set("search",b),a&&s.set("sort",a),o&&s.set("order",o),c&&s.set("page",c),i&&s.set("per_page",i),p){const R=JSON.parse(p);Object.entries(R).forEach(([y,A])=>{A&&s.set(y,A)})}const E=await O(`${N}/${e}?${s}`,{signal:g.signal});if(E.status===401)return;const f=await E.json();f.success?(h(f.data[r]||[]),f.data.pagination&&k(f.data.pagination)):u.error(f.error||d)}catch(s){if(s.name==="AbortError")return;u.error("Chyba připojení")}finally{D(!1)}},[u,e,r,b,a,o,c,i,p,d]);return t.useEffect(()=>(C(),()=>{l.current&&l.current.abort()}),[C]),{items:m,setItems:h,loading:j,pagination:w,refetch:C}}export{J as S,_ as a,V as u};

2
dist/index.html vendored
View File

@@ -29,7 +29,7 @@
<link <link
href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=Urbanist:wght@400;500;600;700;800&display=swap"
rel="stylesheet" /> rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-CnEy_BDh.js"></script> <script type="module" crossorigin src="/assets/index-CNxd7jIT.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-react-BVs3cwbi.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-animation-0s3FMHwK.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js"> <link rel="modulepreload" crossorigin href="/assets/vendor-utils-Dyr8OjFr.js">

View File

@@ -3,7 +3,7 @@
'name' => 'boha/website', 'name' => 'boha/website',
'pretty_version' => 'dev-master', 'pretty_version' => 'dev-master',
'version' => 'dev-master', 'version' => 'dev-master',
'reference' => '10fbb9ebc7c5fecb2f704317c3024d5be8fc2d91', 'reference' => '308941449e1b5f1ec6752c297d286e6633c11fed',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),
@@ -13,7 +13,7 @@
'boha/website' => array( 'boha/website' => array(
'pretty_version' => 'dev-master', 'pretty_version' => 'dev-master',
'version' => 'dev-master', 'version' => 'dev-master',
'reference' => '10fbb9ebc7c5fecb2f704317c3024d5be8fc2d91', 'reference' => '308941449e1b5f1ec6752c297d286e6633c11fed',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),

View File

@@ -20,8 +20,10 @@ export default function ProjectCreate() {
name: '', name: '',
customer_id: null, customer_id: null,
customer_name: '', customer_name: '',
start_date: new Date().toISOString().split('T')[0] start_date: new Date().toISOString().split('T')[0],
responsible_user_id: ''
}) })
const [users, setUsers] = useState([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState({}) const [errors, setErrors] = useState({})
const [loadingNumber, setLoadingNumber] = useState(true) const [loadingNumber, setLoadingNumber] = useState(true)
@@ -35,9 +37,10 @@ export default function ProjectCreate() {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
const [numRes, custRes] = await Promise.all([ const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects.php?action=next_number`), apiFetch(`${API_BASE}/projects.php?action=next_number`),
apiFetch(`${API_BASE}/customers.php`) apiFetch(`${API_BASE}/customers.php`),
apiFetch(`${API_BASE}/projects.php?action=users`)
]) ])
const numData = await numRes.json() const numData = await numRes.json()
@@ -49,6 +52,11 @@ export default function ProjectCreate() {
if (custData.success) { if (custData.success) {
setCustomers(custData.data.customers) setCustomers(custData.data.customers)
} }
const usersData = await usersRes.json()
if (usersData.success) {
setUsers(usersData.data.users)
}
} catch { } catch {
alert.error('Chyba při načítání dat') alert.error('Chyba při načítání dat')
} finally { } finally {
@@ -109,7 +117,8 @@ export default function ProjectCreate() {
name: form.name.trim(), name: form.name.trim(),
customer_id: form.customer_id, customer_id: form.customer_id,
start_date: form.start_date, start_date: form.start_date,
project_number: form.project_number.trim() project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null
} }
const res = await apiFetch(`${API_BASE}/projects.php`, { const res = await apiFetch(`${API_BASE}/projects.php`, {
@@ -263,6 +272,21 @@ export default function ProjectCreate() {
/> />
</FormField> </FormField>
</div> </div>
<div className="admin-form-row">
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -42,8 +42,10 @@ export default function ProjectDetail() {
name: '', name: '',
status: 'aktivni', status: 'aktivni',
start_date: '', start_date: '',
end_date: '' end_date: '',
responsible_user_id: ''
}) })
const [users, setUsers] = useState([])
const [deleteConfirm, setDeleteConfirm] = useState(false) const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
@@ -91,7 +93,8 @@ export default function ProjectDetail() {
name: p.name || '', name: p.name || '',
status: p.status || 'aktivni', status: p.status || 'aktivni',
start_date: (p.start_date || '').substring(0, 10), start_date: (p.start_date || '').substring(0, 10),
end_date: (p.end_date || '').substring(0, 10) end_date: (p.end_date || '').substring(0, 10),
responsible_user_id: p.responsible_user_id || ''
}) })
} else { } else {
alert.error(result.error || 'Nepodařilo se načíst projekt') alert.error(result.error || 'Nepodařilo se načíst projekt')
@@ -104,8 +107,22 @@ export default function ProjectDetail() {
setLoading(false) setLoading(false)
} }
} }
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/projects.php?action=users`)
if (res.status === 401) return
const data = await res.json()
if (data.success) {
setUsers(data.data.users || [])
}
} catch {
// silent
}
}
fetchDetail() fetchDetail()
fetchNotes() fetchNotes()
fetchUsers()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps }, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden /> if (!hasPermission('projects.view')) return <Forbidden />
@@ -127,7 +144,8 @@ export default function ProjectDetail() {
name: form.name, name: form.name,
status: form.status, status: form.status,
start_date: form.start_date || null, start_date: form.start_date || null,
end_date: form.end_date || null end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null
}) })
}) })
const result = await response.json() const result = await response.json()
@@ -332,6 +350,22 @@ export default function ProjectDetail() {
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }} style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
/> />
</FormField> </FormField>
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Stav"> <FormField label="Stav">
<select <select
value={form.status} value={form.status}
@@ -344,9 +378,6 @@ export default function ProjectDetail() {
<option value="zruseny">Zrušený</option> <option value="zruseny">Zrušený</option>
</select> </select>
</FormField> </FormField>
</div>
<div className="admin-form-row">
<FormField label="Datum zahájení"> <FormField label="Datum zahájení">
<AdminDatePicker <AdminDatePicker
mode="date" mode="date"

View File

@@ -189,6 +189,7 @@ export default function Projects() {
Název <SortIcon column="name" sort={activeSort} order={order} /> Název <SortIcon column="name" sort={activeSort} order={order} />
</th> </th>
<th>Zákazník</th> <th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}> <th style={{ cursor: 'pointer' }} onClick={() => handleSort('status')}>
Stav <SortIcon column="status" sort={activeSort} order={order} /> Stav <SortIcon column="status" sort={activeSort} order={order} />
</th> </th>
@@ -212,6 +213,7 @@ export default function Projects() {
</td> </td>
<td className="fw-500">{p.name || '—'}</td> <td className="fw-500">{p.name || '—'}</td>
<td>{p.customer_name || '—'}</td> <td>{p.customer_name || '—'}</td>
<td>{p.responsible_user_name || '—'}</td>
<td> <td>
<span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}> <span className={`admin-badge ${STATUS_CLASSES[p.status] || ''}`}>
{STATUS_LABELS[p.status] || p.status} {STATUS_LABELS[p.status] || p.status}