feat: filemanager - plná cesta na disku, podpora symlinků, oprava stylů

- Backend: listFiles vrací full_path, detekce symlinků/junctions
- Backend: resolveProjectPath povoluje navigaci přes symlinky
- Frontend: zobrazení plné cesty pod breadcrumbem
- Frontend: ikona odkazu u symlinků s tooltipem cíle
- Fix: underline jen na názvu složky, ne na počtu položek

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 14:10:00 +01:00
parent 019a8355dc
commit 4c8a41c107
41 changed files with 337 additions and 89 deletions

View File

@@ -94,7 +94,7 @@ class NasFileManager
/** /**
* Seznam souboru a slozek v dane ceste * Seznam souboru a slozek v dane ceste
* *
* @return array{path: string, items: list<array<string, mixed>>}|null * @return array{path: string, items: list<array<string, mixed>>, full_path: string}|null
*/ */
public function listFiles(string $projectNumber, string $subPath = ''): ?array public function listFiles(string $projectNumber, string $subPath = ''): ?array
{ {
@@ -115,14 +115,23 @@ class NasFileManager
} }
$fullPath = $dirPath . '/' . $entry; $fullPath = $dirPath . '/' . $entry;
$isLink = is_link($fullPath) || $this->isJunction($fullPath);
$isDir = is_dir($fullPath); $isDir = is_dir($fullPath);
$item = [ $item = [
'name' => $entry, 'name' => $entry,
'type' => $isDir ? 'folder' : 'file', 'type' => $isDir ? 'folder' : 'file',
'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0), 'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0),
'is_symlink' => $isLink,
]; ];
if ($isLink) {
$target = $this->readLinkTarget($fullPath);
if ($target !== null) {
$item['link_target'] = $target;
}
}
if ($isDir) { if ($isDir) {
$item['item_count'] = $this->countItems($fullPath); $item['item_count'] = $this->countItems($fullPath);
} else { } else {
@@ -151,10 +160,14 @@ class NasFileManager
} }
} }
// Realna cesta na disku (pro zobrazeni v UI)
$realDirPath = realpath($dirPath);
return [ return [
'path' => $subPath, 'path' => $subPath,
'items' => $items, 'items' => $items,
'breadcrumb' => $breadcrumb, 'breadcrumb' => $breadcrumb,
'full_path' => str_replace('/', '\\', $realDirPath ?: $dirPath),
]; ];
} }
@@ -387,7 +400,9 @@ class NasFileManager
/** /**
* Resolve a project sub-path to a safe absolute path * Resolve a project sub-path to a safe absolute path
* Returns null if path traversal detected * Returns null if path traversal detected.
* Symlinky/junctions jsou povoleny - realpath kontrola se preskoci
* pokud je v ceste symlink.
*/ */
private function resolveProjectPath(string $projectNumber, string $subPath): ?string private function resolveProjectPath(string $projectNumber, string $subPath): ?string
{ {
@@ -410,29 +425,66 @@ class NasFileManager
$candidate = $folderPath . '/' . $subPath; $candidate = $folderPath . '/' . $subPath;
if (!file_exists($candidate)) {
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$normalBase = str_replace('\\', '/', $folderPath);
// Rodic musi byt v projektu nebo dosazitelny pres symlink z projektu
if (!$this->isPathReachable($parentDir, $normalBase)) {
return null;
}
}
return $candidate;
}
// Existujici cesta - overi ze je dosazitelna z projektove slozky
// (bud primo uvnitr, nebo pres symlink ktery je v projektu)
$normalBase = str_replace('\\', '/', $folderPath); $normalBase = str_replace('\\', '/', $folderPath);
if (!$this->isPathReachable($candidate, $normalBase)) {
// realpath kontrola - soubor/slozka musi existovat pro existujici cesty return null;
if (file_exists($candidate)) {
$real = realpath($candidate);
$normalReal = str_replace('\\', '/', (string) $real);
if ($real === false || !str_starts_with($normalReal, $normalBase)) {
return null;
}
return $normalReal;
} }
// Pro nove soubory/slozky - kontrola rodice return str_replace('\\', '/', $candidate);
$parentDir = dirname($candidate); }
if (file_exists($parentDir)) {
$realParent = realpath($parentDir); /**
$normalParent = str_replace('\\', '/', (string) $realParent); * Overi ze cesta je dosazitelna z projektove slozky.
if ($realParent === false || !str_starts_with($normalParent, $normalBase)) { * Projde cestu segment po segmentu - pokud narazi na symlink
return null; * v projektove slozce, povoli ho.
*/
private function isPathReachable(string $path, string $projectBase): bool
{
$normalPath = str_replace('\\', '/', $path);
// Primo v projektove slozce (bez symlinku)
$real = realpath($path);
if ($real !== false) {
$normalReal = str_replace('\\', '/', $real);
if (str_starts_with($normalReal, $projectBase)) {
return true;
} }
} }
return $candidate; // Cesta muze vest pres symlink - overime ze symlink samotny
// je uvnitr projektove slozky
$parts = explode('/', str_replace('\\', '/', $normalPath));
$baseParts = explode('/', $projectBase);
$baseLen = count($baseParts);
$current = '';
foreach ($parts as $i => $part) {
$current .= ($i > 0 ? '/' : '') . $part;
if ($i < $baseLen) {
continue;
}
// Segment za projektovou slozkou - pokud je symlink, je to OK
if (file_exists($current) && (is_link($current) || $this->isJunction($current))) {
return true;
}
}
return false;
} }
private function deleteRecursive(string $path): bool private function deleteRecursive(string $path): bool
@@ -495,6 +547,55 @@ class NasFileManager
return round($bytes / 1073741824, 1) . ' GB'; return round($bytes / 1073741824, 1) . ' GB';
} }
/**
* Detekce NTFS junction pointu (Windows)
*/
private function isJunction(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return is_link($path);
}
if (!file_exists($path)) {
return false;
}
$attr = @exec('fsutil reparsepoint query "' . str_replace('/', '\\', $path) . '" 2>NUL');
if ($attr !== false && $attr !== '') {
return true;
}
// Fallback - realpath se lisi od puvodniho path u junction
$real = realpath($path);
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', (string) $real);
return $real !== false && strtolower($normalized) !== strtolower($normalReal) && is_dir($path);
}
/**
* Precte cil symlinku/junction
*/
private function readLinkTarget(string $path): ?string
{
// PHP is_link + readlink
if (is_link($path)) {
$target = readlink($path);
return $target !== false ? str_replace('/', '\\', $target) : null;
}
// Windows junction - realpath ukazuje na cil
$real = realpath($path);
if ($real !== false) {
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', $real);
if (strtolower($normalized) !== strtolower($normalReal)) {
return str_replace('/', '\\', $real);
}
}
return null;
}
/** /**
* Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg) * Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg)
*/ */

View File

@@ -94,7 +94,7 @@ class NasFileManager
/** /**
* Seznam souboru a slozek v dane ceste * Seznam souboru a slozek v dane ceste
* *
* @return array{path: string, items: list<array<string, mixed>>}|null * @return array{path: string, items: list<array<string, mixed>>, full_path: string}|null
*/ */
public function listFiles(string $projectNumber, string $subPath = ''): ?array public function listFiles(string $projectNumber, string $subPath = ''): ?array
{ {
@@ -115,14 +115,23 @@ class NasFileManager
} }
$fullPath = $dirPath . '/' . $entry; $fullPath = $dirPath . '/' . $entry;
$isLink = is_link($fullPath) || $this->isJunction($fullPath);
$isDir = is_dir($fullPath); $isDir = is_dir($fullPath);
$item = [ $item = [
'name' => $entry, 'name' => $entry,
'type' => $isDir ? 'folder' : 'file', 'type' => $isDir ? 'folder' : 'file',
'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0), 'modified' => date('Y-m-d H:i', filemtime($fullPath) ?: 0),
'is_symlink' => $isLink,
]; ];
if ($isLink) {
$target = $this->readLinkTarget($fullPath);
if ($target !== null) {
$item['link_target'] = $target;
}
}
if ($isDir) { if ($isDir) {
$item['item_count'] = $this->countItems($fullPath); $item['item_count'] = $this->countItems($fullPath);
} else { } else {
@@ -151,10 +160,14 @@ class NasFileManager
} }
} }
// Realna cesta na disku (pro zobrazeni v UI)
$realDirPath = realpath($dirPath);
return [ return [
'path' => $subPath, 'path' => $subPath,
'items' => $items, 'items' => $items,
'breadcrumb' => $breadcrumb, 'breadcrumb' => $breadcrumb,
'full_path' => str_replace('/', '\\', $realDirPath ?: $dirPath),
]; ];
} }
@@ -387,7 +400,9 @@ class NasFileManager
/** /**
* Resolve a project sub-path to a safe absolute path * Resolve a project sub-path to a safe absolute path
* Returns null if path traversal detected * Returns null if path traversal detected.
* Symlinky/junctions jsou povoleny - realpath kontrola se preskoci
* pokud je v ceste symlink.
*/ */
private function resolveProjectPath(string $projectNumber, string $subPath): ?string private function resolveProjectPath(string $projectNumber, string $subPath): ?string
{ {
@@ -410,29 +425,66 @@ class NasFileManager
$candidate = $folderPath . '/' . $subPath; $candidate = $folderPath . '/' . $subPath;
if (!file_exists($candidate)) {
// Pro nove soubory/slozky - kontrola rodice
$parentDir = dirname($candidate);
if (file_exists($parentDir)) {
$normalBase = str_replace('\\', '/', $folderPath);
// Rodic musi byt v projektu nebo dosazitelny pres symlink z projektu
if (!$this->isPathReachable($parentDir, $normalBase)) {
return null;
}
}
return $candidate;
}
// Existujici cesta - overi ze je dosazitelna z projektove slozky
// (bud primo uvnitr, nebo pres symlink ktery je v projektu)
$normalBase = str_replace('\\', '/', $folderPath); $normalBase = str_replace('\\', '/', $folderPath);
if (!$this->isPathReachable($candidate, $normalBase)) {
// realpath kontrola - soubor/slozka musi existovat pro existujici cesty return null;
if (file_exists($candidate)) {
$real = realpath($candidate);
$normalReal = str_replace('\\', '/', (string) $real);
if ($real === false || !str_starts_with($normalReal, $normalBase)) {
return null;
}
return $normalReal;
} }
// Pro nove soubory/slozky - kontrola rodice return str_replace('\\', '/', $candidate);
$parentDir = dirname($candidate); }
if (file_exists($parentDir)) {
$realParent = realpath($parentDir); /**
$normalParent = str_replace('\\', '/', (string) $realParent); * Overi ze cesta je dosazitelna z projektove slozky.
if ($realParent === false || !str_starts_with($normalParent, $normalBase)) { * Projde cestu segment po segmentu - pokud narazi na symlink
return null; * v projektove slozce, povoli ho.
*/
private function isPathReachable(string $path, string $projectBase): bool
{
$normalPath = str_replace('\\', '/', $path);
// Primo v projektove slozce (bez symlinku)
$real = realpath($path);
if ($real !== false) {
$normalReal = str_replace('\\', '/', $real);
if (str_starts_with($normalReal, $projectBase)) {
return true;
} }
} }
return $candidate; // Cesta muze vest pres symlink - overime ze symlink samotny
// je uvnitr projektove slozky
$parts = explode('/', str_replace('\\', '/', $normalPath));
$baseParts = explode('/', $projectBase);
$baseLen = count($baseParts);
$current = '';
foreach ($parts as $i => $part) {
$current .= ($i > 0 ? '/' : '') . $part;
if ($i < $baseLen) {
continue;
}
// Segment za projektovou slozkou - pokud je symlink, je to OK
if (file_exists($current) && (is_link($current) || $this->isJunction($current))) {
return true;
}
}
return false;
} }
private function deleteRecursive(string $path): bool private function deleteRecursive(string $path): bool
@@ -495,6 +547,55 @@ class NasFileManager
return round($bytes / 1073741824, 1) . ' GB'; return round($bytes / 1073741824, 1) . ' GB';
} }
/**
* Detekce NTFS junction pointu (Windows)
*/
private function isJunction(string $path): bool
{
if (PHP_OS_FAMILY !== 'Windows') {
return is_link($path);
}
if (!file_exists($path)) {
return false;
}
$attr = @exec('fsutil reparsepoint query "' . str_replace('/', '\\', $path) . '" 2>NUL');
if ($attr !== false && $attr !== '') {
return true;
}
// Fallback - realpath se lisi od puvodniho path u junction
$real = realpath($path);
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', (string) $real);
return $real !== false && strtolower($normalized) !== strtolower($normalReal) && is_dir($path);
}
/**
* Precte cil symlinku/junction
*/
private function readLinkTarget(string $path): ?string
{
// PHP is_link + readlink
if (is_link($path)) {
$target = readlink($path);
return $target !== false ? str_replace('/', '\\', $target) : null;
}
// Windows junction - realpath ukazuje na cil
$real = realpath($path);
if ($real !== false) {
$normalized = str_replace('\\', '/', $path);
$normalReal = str_replace('\\', '/', $real);
if (strtolower($normalized) !== strtolower($normalReal)) {
return str_replace('/', '\\', $real);
}
}
return null;
}
/** /**
* Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg) * Detekce podezrelych MIME typů (napr. exe maskujici se jako jpg)
*/ */

View File

@@ -1 +1 @@
{"window_start":1773405014,"count":4} {"window_start":1773406850,"count":6}

View File

@@ -1 +1 @@
{"window_start":1773404939,"count":1} {"window_start":1773406619,"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-Bay45BGf.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-c9Us0bor.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

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-DBLvWP4x.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-Bay45BGf.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-c9Us0bor.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

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-Bay45BGf.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-c9Us0bor.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};

4
dist/index.html vendored
View File

@@ -29,11 +29,11 @@
<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-Bay45BGf.js"></script> <script type="module" crossorigin src="/assets/index-c9Us0bor.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">
<link rel="stylesheet" crossorigin href="/assets/index-S7b0Xjr1.css"> <link rel="stylesheet" crossorigin href="/assets/index-eC-aMCY3.css">
</head> </head>
<body style="background-color: var(--bg-primary, #12121a);"> <body style="background-color: var(--bg-primary, #12121a);">

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' => '45fd930f76debb906f6a51b10aab2cb0937ac200', 'reference' => '019a8355dc85203c5fc76591ec9c01a01068edc9',
'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' => '45fd930f76debb906f6a51b10aab2cb0937ac200', 'reference' => '019a8355dc85203c5fc76591ec9c01a01068edc9',
'type' => 'project', 'type' => 'project',
'install_path' => __DIR__ . '/../../', 'install_path' => __DIR__ . '/../../',
'aliases' => array(), 'aliases' => array(),

View File

@@ -2514,6 +2514,17 @@ img {
flex-wrap: wrap; flex-wrap: wrap;
} }
.fm-full-path {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-tertiary);
user-select: all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.fm-toolbar-actions { .fm-toolbar-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -2621,9 +2632,6 @@ img {
font-size: inherit; font-size: inherit;
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
} }
.fm-folder-link:hover { .fm-folder-link:hover {
@@ -2652,3 +2660,16 @@ img {
justify-content: flex-end; justify-content: flex-end;
} }
.fm-name-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-symlink-badge {
display: inline-flex;
align-items: center;
color: var(--text-tertiary);
cursor: help;
}

View File

@@ -46,22 +46,41 @@ function getFileIcon(type, extension) {
) )
} }
function SymlinkBadge({ target }) {
return (
<span className="fm-symlink-badge" title={target || 'Odkaz'}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
</span>
)
}
function FileNameCell({ item, onFolderClick }) { function FileNameCell({ item, onFolderClick }) {
if (item.type === 'folder') { if (item.type === 'folder') {
return ( return (
<button <span className="fm-name-cell">
type="button" <button
className="fm-folder-link" type="button"
onClick={() => onFolderClick(item.name)} className="fm-folder-link"
> onClick={() => onFolderClick(item.name)}
{item.name} >
{item.name}
</button>
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
{item.item_count !== undefined && ( {item.item_count !== undefined && (
<span className="fm-item-count">{item.item_count}</span> <span className="fm-item-count">{item.item_count}</span>
)} )}
</button> </span>
) )
} }
return <span className="fm-file-name">{item.name}</span> return (
<span className="fm-name-cell">
<span className="fm-file-name">{item.name}</span>
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
</span>
)
} }
export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder }) { export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder }) {
@@ -72,6 +91,7 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [currentPath, setCurrentPath] = useState('') const [currentPath, setCurrentPath] = useState('')
const [breadcrumb, setBreadcrumb] = useState(['']) const [breadcrumb, setBreadcrumb] = useState([''])
const [fullPath, setFullPath] = useState('')
const [dragOver, setDragOver] = useState(false) const [dragOver, setDragOver] = useState(false)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
@@ -105,6 +125,7 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis
setItems(data.data.items || []) setItems(data.data.items || [])
setBreadcrumb(data.data.breadcrumb || ['']) setBreadcrumb(data.data.breadcrumb || [''])
setCurrentPath(data.data.path || '') setCurrentPath(data.data.path || '')
setFullPath(data.data.full_path || '')
} else if (res.status === 404) { } else if (res.status === 404) {
setItems([]) setItems([])
setBreadcrumb(['']) setBreadcrumb([''])
@@ -403,6 +424,10 @@ export default function ProjectFileManager({ projectId, projectNumber, hasPermis
))} ))}
</div> </div>
{fullPath && (
<span className="fm-full-path" title={fullPath}>{fullPath}</span>
)}
{canManage && ( {canManage && (
<div className="fm-toolbar-actions"> <div className="fm-toolbar-actions">
<button <button