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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
614
src/admin/components/ProjectFileManager.jsx
Normal file
614
src/admin/components/ProjectFileManager.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 "{order.order_number}"? 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"
|
||||
|
||||
@@ -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 "{deleteConfirm.order?.order_number}"? 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"
|
||||
|
||||
@@ -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 "{project.project_number} – {project.name}"? 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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user