feat: filemanager s NAS pro projekty

- NasFileManager.php - filesystem helper (browse, upload, download, delete, rename, mkdir)
- project-files.php API - CRUD operace nad soubory projektu
- ProjectFileManager.jsx - React komponenta v detailu projektu
- Automaticke vytvoreni slozky pri vytvoreni projektu (rucne i z objednavky)
- Prejmenovani slozky pri zmene nazvu projektu
- Checkbox "Smazat i soubory na disku" pri mazani projektu/objednavky
- Path traversal ochrana, MIME validace, blocklist nebezpecnych typu
- Bily spinner v primary tlacitkach, ConfirmModal message jako div
- Case-insensitive rename fix pro Windows filesystem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 13:06:34 +01:00
parent 9e3c95e576
commit 45fd930f76
69 changed files with 2776 additions and 71 deletions

View File

@@ -713,6 +713,11 @@ img {
box-shadow: 0 4px 12px rgba(214, 48, 49, 0.3);
}
.admin-btn-primary .admin-spinner {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
.admin-btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -2496,3 +2501,154 @@ img {
white-space: nowrap;
}
/* ============================================================================
File Manager
============================================================================ */
.fm-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.fm-toolbar-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.fm-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
font-size: 12px;
min-height: 28px;
}
.fm-breadcrumb-segment {
display: inline-flex;
align-items: center;
}
.fm-breadcrumb-sep {
color: var(--text-tertiary);
margin: 0 4px;
user-select: none;
}
.fm-breadcrumb-btn {
background: none;
border: none;
padding: 2px 6px;
border-radius: 4px;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
transition: all 0.15s ease;
}
.fm-breadcrumb-btn:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.fm-breadcrumb-btn.active {
color: var(--text-primary);
font-weight: 600;
}
.fm-new-folder {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.fm-new-folder .admin-form-input {
max-width: 250px;
}
.fm-content {
position: relative;
border-radius: var(--border-radius-sm);
transition: border-color 0.2s ease;
}
.fm-content.fm-drag-over {
border: 2px dashed var(--accent-color);
background: var(--accent-light);
}
.fm-dropzone-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: color-mix(in srgb, var(--bg-primary) 90%, transparent);
border-radius: var(--border-radius-sm);
z-index: 5;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
pointer-events: none;
}
.fm-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
color: var(--text-tertiary);
font-size: 13px;
}
.fm-folder-link {
background: none;
border: none;
padding: 0;
color: var(--accent-color);
font-weight: 500;
font-size: inherit;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.fm-folder-link:hover {
text-decoration: underline;
}
.fm-item-count {
font-size: 10px;
color: var(--text-tertiary);
font-weight: 400;
}
.fm-file-name {
color: var(--text-primary);
}
.fm-meta {
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
}
.fm-actions {
display: inline-flex;
gap: 2px;
justify-content: flex-end;
}

View File

@@ -87,7 +87,7 @@ export default function ConfirmModal({
{icons[type]}
</div>
<h2 id="confirm-modal-title" className="admin-confirm-title">{title}</h2>
<p className="admin-confirm-message">{message}</p>
<div className="admin-confirm-message">{message}</div>
</div>
<div className="admin-modal-footer">

View File

@@ -0,0 +1,614 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import ConfirmModal from './ConfirmModal'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
function getFileIcon(type, extension) {
if (type === 'folder') {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#e6a817" strokeWidth="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" fill="rgba(230, 168, 23, 0.15)" />
</svg>
)
}
const ext = (extension || '').toLowerCase()
const iconMap = {
pdf: { color: '#e74c3c', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
doc: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
docx: { color: '#3498db', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
xls: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
xlsx: { color: '#27ae60', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
ppt: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
pptx: { color: '#e67e22', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
jpg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
jpeg: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
png: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
gif: { color: '#3498db', path: 'M19 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2z' },
zip: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
rar: { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
'7z': { color: '#e67e22', path: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
dwg: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
dxf: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
step: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
stp: { color: '#8e44ad', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' },
}
const cfg = iconMap[ext] || { color: 'var(--text-muted)', path: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z' }
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={cfg.color} strokeWidth="1.5">
<path d={cfg.path} />
<polyline points="14 2 14 8 20 8" />
</svg>
)
}
function FileNameCell({ item, onFolderClick }) {
if (item.type === 'folder') {
return (
<button
type="button"
className="fm-folder-link"
onClick={() => onFolderClick(item.name)}
>
{item.name}
{item.item_count !== undefined && (
<span className="fm-item-count">{item.item_count}</span>
)}
</button>
)
}
return <span className="fm-file-name">{item.name}</span>
}
export default function ProjectFileManager({ projectId, projectNumber, hasPermission, hasNasFolder }) {
const alert = useAlert()
const fileInputRef = useRef(null)
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [currentPath, setCurrentPath] = useState('')
const [breadcrumb, setBreadcrumb] = useState([''])
const [dragOver, setDragOver] = useState(false)
const [uploading, setUploading] = useState(false)
const [newFolderMode, setNewFolderMode] = useState(false)
const [newFolderName, setNewFolderName] = useState('')
const [creatingFolder, setCreatingFolder] = useState(false)
const [renamingItem, setRenamingItem] = useState(null)
const [renameValue, setRenameValue] = useState('')
const [deleteTarget, setDeleteTarget] = useState(null)
const [deleting, setDeleting] = useState(false)
const [errorMessage, setErrorMessage] = useState(null)
const canManage = hasPermission('projects.files')
const fetchFiles = useCallback(async (path = '', options = {}) => {
setLoading(true)
setErrorMessage(null)
try {
const params = new URLSearchParams({ project_id: projectId })
if (path) {
params.set('path', path)
}
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`)
if (options.ignore) return
if (res.status === 401) return
const data = await res.json()
if (data.success) {
setItems(data.data.items || [])
setBreadcrumb(data.data.breadcrumb || [''])
setCurrentPath(data.data.path || '')
} else if (res.status === 404) {
setItems([])
setBreadcrumb([''])
} else {
setErrorMessage(data.error || 'Nepodařilo se načíst soubory')
}
} catch {
if (!options.ignore) {
setErrorMessage('Chyba připojení')
}
} finally {
if (!options.ignore) {
setLoading(false)
}
}
}, [projectId])
useEffect(() => {
const opts = { ignore: false }
fetchFiles('', opts)
return () => { opts.ignore = true }
}, [fetchFiles])
const navigateTo = (path) => {
setNewFolderMode(false)
setRenamingItem(null)
fetchFiles(path)
}
const handleBreadcrumbClick = (index) => {
if (index === 0) {
navigateTo('')
return
}
const path = breadcrumb.slice(1, index + 1).join('/')
navigateTo(path)
}
const handleFolderClick = (folderName) => {
const path = currentPath ? `${currentPath}/${folderName}` : folderName
navigateTo(path)
}
const handleUpload = async (files) => {
if (!files || files.length === 0) return
setUploading(true)
let successCount = 0
let errorMsg = null
for (const file of files) {
const formData = new FormData()
formData.append('file', file)
const params = new URLSearchParams({
action: 'upload',
project_id: projectId,
})
if (currentPath) {
params.set('path', currentPath)
}
try {
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'POST',
body: formData,
})
const data = await res.json()
if (data.success) {
successCount++
} else {
errorMsg = data.error || 'Chyba při nahrávání'
}
} catch {
errorMsg = 'Chyba připojení'
}
}
setUploading(false)
if (successCount > 0) {
const msg = successCount === 1 ? 'Soubor byl nahrán' : `Nahráno ${successCount} souborů`
alert.success(msg)
fetchFiles(currentPath)
}
if (errorMsg) {
alert.error(errorMsg)
}
}
const handleFileInputChange = (e) => {
handleUpload(e.target.files)
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
if (!canManage) return
handleUpload(e.dataTransfer.files)
}
const handleDragOver = (e) => {
e.preventDefault()
if (canManage) {
setDragOver(true)
}
}
const handleDragLeave = (e) => {
e.preventDefault()
setDragOver(false)
}
const handleCreateFolder = async () => {
const name = newFolderName.trim()
if (!name) return
setCreatingFolder(true)
try {
const params = new URLSearchParams({
action: 'create_folder',
project_id: projectId,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: currentPath, folder_name: name }),
})
const data = await res.json()
if (data.success) {
alert.success('Složka byla vytvořena')
setNewFolderMode(false)
setNewFolderName('')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se vytvořit složku')
}
} catch {
alert.error('Chyba připojení')
} finally {
setCreatingFolder(false)
}
}
const handleDownload = (item) => {
const filePath = currentPath ? `${currentPath}/${item.name}` : item.name
const params = new URLSearchParams({
action: 'download',
project_id: projectId,
path: filePath,
})
window.open(`${API_BASE}/project-files.php?${params}`, '_blank')
}
const handleDelete = async () => {
if (!deleteTarget) return
setDeleting(true)
const filePath = currentPath ? `${currentPath}/${deleteTarget.name}` : deleteTarget.name
try {
const params = new URLSearchParams({
project_id: projectId,
path: filePath,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'DELETE',
})
const data = await res.json()
if (data.success) {
alert.success(deleteTarget.type === 'folder' ? 'Složka byla smazána' : 'Soubor byl smazán')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se smazat')
}
} catch {
alert.error('Chyba připojení')
} finally {
setDeleting(false)
setDeleteTarget(null)
}
}
const handleRename = async (item) => {
const newName = renameValue.trim()
if (!newName || newName === item.name) {
setRenamingItem(null)
return
}
const fromPath = currentPath ? `${currentPath}/${item.name}` : item.name
const toPath = currentPath ? `${currentPath}/${newName}` : newName
try {
const params = new URLSearchParams({
action: 'move',
project_id: projectId,
})
const res = await apiFetch(`${API_BASE}/project-files.php?${params}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ from_path: fromPath, to_path: toPath }),
})
const data = await res.json()
if (data.success) {
alert.success('Přejmenováno')
fetchFiles(currentPath)
} else {
alert.error(data.error || 'Nepodařilo se přejmenovat')
}
} catch {
alert.error('Chyba připojení')
} finally {
setRenamingItem(null)
}
}
const startRename = (item) => {
setRenamingItem(item.name)
setRenameValue(item.name)
}
if (loading && items.length === 0 && !errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="admin-skeleton" style={{ padding: 0, gap: '0.5rem' }}>
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line" style={{ width: '18px', height: '18px', borderRadius: '4px', flexShrink: 0 }} />
<div className="admin-skeleton-line" style={{ width: `${60 + i * 10}%` }} />
</div>
))}
</div>
</div>
</div>
)
}
if (errorMessage) {
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
<div className="fm-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{errorMessage}</span>
</div>
</div>
</div>
)
}
return (
<div className="admin-card">
<div className="admin-card-body">
<h3 className="admin-card-title">Soubory</h3>
{/* Toolbar */}
<div className="fm-toolbar">
<div className="fm-breadcrumb">
{breadcrumb.map((segment, i) => (
<span key={i} className="fm-breadcrumb-segment">
{i > 0 && <span className="fm-breadcrumb-sep">/</span>}
<button
type="button"
className={`fm-breadcrumb-btn ${i === breadcrumb.length - 1 ? 'active' : ''}`}
onClick={() => handleBreadcrumbClick(i)}
>
{i === 0 ? projectNumber : segment}
</button>
</span>
))}
</div>
{canManage && (
<div className="fm-toolbar-actions">
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(!newFolderMode)
setNewFolderName('')
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
Složka
</button>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Nahrávání...
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Nahrát
</>
)}
</button>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: 'none' }}
onChange={handleFileInputChange}
/>
</div>
)}
</div>
{/* New folder input */}
{newFolderMode && (
<div className="fm-new-folder">
<input
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="admin-form-input"
placeholder="Název složky..."
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreateFolder()
if (e.key === 'Escape') {
setNewFolderMode(false)
setNewFolderName('')
}
}}
style={{ fontSize: '12px', padding: '6px 10px' }}
/>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={handleCreateFolder}
disabled={creatingFolder || !newFolderName.trim()}
>
{creatingFolder ? <div className="admin-spinner admin-spinner-sm" /> : 'Vytvořit'}
</button>
<button
type="button"
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => {
setNewFolderMode(false)
setNewFolderName('')
}}
>
Zrušit
</button>
</div>
)}
{/* Drop zone + table */}
<div
className={`fm-content ${dragOver ? 'fm-drag-over' : ''}`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
{dragOver && (
<div className="fm-dropzone-overlay">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span>Přetáhněte soubory sem</span>
</div>
)}
{items.length === 0 && !loading ? (
<div className="fm-empty">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span>
{hasNasFolder ? 'Složka je prázdná' : 'Složka projektu zatím neexistuje'}
</span>
{canManage && !hasNasFolder && (
<span style={{ fontSize: '11px' }}>Nahrání souboru ji automaticky vytvoří</span>
)}
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th>Název</th>
<th style={{ width: '90px' }}>Velikost</th>
<th style={{ width: '120px' }}>Změněno</th>
{canManage && <th style={{ width: '100px', textAlign: 'right' }}>Akce</th>}
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.name}>
<td style={{ textAlign: 'center' }}>
{getFileIcon(item.type, item.extension)}
</td>
<td>
{renamingItem === item.name ? (
<input
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
className="admin-form-input"
style={{ fontSize: '11px', padding: '3px 8px', maxWidth: '300px' }}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleRename(item)
if (e.key === 'Escape') setRenamingItem(null)
}}
onBlur={() => handleRename(item)}
/>
) : (
<FileNameCell item={item} onFolderClick={handleFolderClick} />
)}
</td>
<td className="fm-meta">
{item.type === 'file' ? item.size_formatted : '—'}
</td>
<td className="fm-meta">{item.modified || '—'}</td>
{canManage && (
<td style={{ textAlign: 'right' }}>
<div className="fm-actions">
{item.type === 'file' && (
<button
type="button"
className="admin-btn-icon"
title="Stáhnout"
onClick={() => handleDownload(item)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
type="button"
className="admin-btn-icon"
title="Přejmenovat"
onClick={() => startRename(item)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
className="admin-btn-icon danger"
title="Smazat"
onClick={() => setDeleteTarget(item)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<ConfirmModal
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDelete}
title={deleteTarget?.type === 'folder' ? 'Smazat složku' : 'Smazat soubor'}
message={`Opravdu chcete smazat "${deleteTarget?.name}"?${deleteTarget?.type === 'folder' ? ' Složka bude smazána včetně veškerého obsahu.' : ''}`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
)
}

View File

@@ -54,6 +54,7 @@ export default function OrderDetail() {
const [attachmentLoading, setAttachmentLoading] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const fetchDetail = async () => {
try {
@@ -197,7 +198,11 @@ export default function OrderDetail() {
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, { method: 'DELETE' })
const response = await apiFetch(`${API_BASE}/orders.php?id=${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
alert.success(result.message || 'Objednávka byla smazána')
@@ -589,10 +594,27 @@ export default function OrderDetail() {
{/* Delete confirmation */}
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${order.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`}
message={
<>
Opravdu chcete smazat objednávku &quot;{order.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
{order.project?.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"

View File

@@ -38,6 +38,7 @@ export default function Orders() {
const [deleteConfirm, setDeleteConfirm] = useState({ show: false, order: null })
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: orders, loading, pagination, refetch: fetchData } = useListData('orders.php', {
dataKey: 'orders', search, sort, order, page,
@@ -51,11 +52,14 @@ export default function Orders() {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/orders.php?id=${deleteConfirm.order.id}`, {
method: 'DELETE'
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána')
fetchData()
} else {
@@ -272,10 +276,25 @@ export default function Orders() {
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, order: null })}
onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={`Opravdu chcete smazat objednávku "${deleteConfirm.order?.order_number}"? Bude smazán i přidružený projekt. Tato akce je nevratná.`}
message={
<>
Opravdu chcete smazat objednávku &quot;{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený projekt. Tato akce je nevratná.
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
@@ -8,6 +8,7 @@ import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import ProjectFileManager from '../components/ProjectFileManager'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
@@ -49,6 +50,7 @@ export default function ProjectDetail() {
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
// Dynamic notes
const [notes, setNotes] = useState([])
@@ -57,10 +59,11 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState(null)
const createdShown = useRef(false)
useEffect(() => {
if (location.state?.created) {
if (location.state?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen')
// Clear state so it doesn't re-show on refresh
navigate(location.pathname, { replace: true, state: {} })
}
}, [location.state]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -164,7 +167,11 @@ export default function ProjectDetail() {
const handleDelete = async () => {
setDeleting(true)
try {
const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, { method: 'DELETE' })
const response = await apiFetch(`${API_BASE}/projects.php?id=${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
if (result.success) {
navigate('/projects')
@@ -523,12 +530,27 @@ export default function ProjectDetail() {
</div>
</motion.div>
{/* Files */}
<motion.div
style={{ marginBottom: '1rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<ProjectFileManager
projectId={id}
projectNumber={project.project_number}
hasPermission={hasPermission}
hasNasFolder={project.has_nas_folder}
/>
</motion.div>
{/* Links */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
transition={{ duration: 0.4, delay: 0.25 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Propojení</h3>
@@ -562,10 +584,27 @@ export default function ProjectDetail() {
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => setDeleteConfirm(false)}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={`Opravdu chcete smazat projekt "${project.project_number} ${project.name}"? Tato akce je nevratná.`}
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
)}
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"

View File

@@ -35,6 +35,7 @@ export default function Projects() {
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { items: projects, setItems: setProjects, loading, pagination } = useListData('projects.php', {
dataKey: 'projects', search, sort, order, page,
@@ -47,7 +48,11 @@ export default function Projects() {
if (!deleteTarget) return
setDeletingId(deleteTarget.id)
try {
const res = await apiFetch(`${API_BASE}/projects.php?id=${deleteTarget.id}`, { method: 'DELETE' })
const res = await apiFetch(`${API_BASE}/projects.php?id=${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json()
if (data.success) {
alert.success(data.message || 'Projekt byl smazán')
@@ -60,6 +65,7 @@ export default function Projects() {
} finally {
setDeletingId(null)
setDeleteTarget(null)
setDeleteFiles(false)
}
}
@@ -268,10 +274,25 @@ export default function Projects() {
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={`Opravdu chcete smazat projekt ${deleteTarget?.project_number}?`}
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}