Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1009 lines
28 KiB
TypeScript
1009 lines
28 KiB
TypeScript
import { useState, useRef } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import Box from "@mui/material/Box";
|
|
import Typography from "@mui/material/Typography";
|
|
import IconButton from "@mui/material/IconButton";
|
|
import CircularProgress from "@mui/material/CircularProgress";
|
|
import { projectFilesOptions } from "../lib/queries/projects";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import {
|
|
Card,
|
|
Button,
|
|
TextField,
|
|
ConfirmDialog,
|
|
EmptyState,
|
|
LoadingState,
|
|
DataTable,
|
|
type DataColumn,
|
|
} from "../ui";
|
|
import apiFetch from "../utils/api";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface ProjectFileManagerProps {
|
|
projectId: number;
|
|
projectNumber: string | null;
|
|
hasPermission: (perm: string) => boolean;
|
|
hasNasFolder: boolean;
|
|
}
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
type: "file" | "folder";
|
|
size?: number;
|
|
size_formatted?: string;
|
|
modified?: string;
|
|
extension?: string;
|
|
item_count?: number;
|
|
is_symlink?: boolean;
|
|
link_target?: string;
|
|
}
|
|
|
|
function getFileIcon(type: string, extension?: string) {
|
|
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: Record<string, { color: string; path: string }> = {
|
|
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(--mui-palette-text-secondary)",
|
|
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 SymlinkBadge({ target }: { target?: string }) {
|
|
return (
|
|
<Box
|
|
component="span"
|
|
title={target || "Odkaz"}
|
|
sx={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
ml: 0.5,
|
|
color: "text.secondary",
|
|
}}
|
|
>
|
|
<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>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
function FileNameCell({
|
|
item,
|
|
onFolderClick,
|
|
}: {
|
|
item: FileItem;
|
|
onFolderClick: (name: string) => void;
|
|
}) {
|
|
if (item.type === "folder") {
|
|
return (
|
|
<Box
|
|
component="span"
|
|
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
|
>
|
|
<Box
|
|
component="button"
|
|
type="button"
|
|
onClick={() => onFolderClick(item.name)}
|
|
sx={{
|
|
background: "none",
|
|
border: "none",
|
|
p: 0,
|
|
font: "inherit",
|
|
color: "primary.main",
|
|
cursor: "pointer",
|
|
textAlign: "left",
|
|
"&:hover": { textDecoration: "underline" },
|
|
}}
|
|
>
|
|
{item.name}
|
|
</Box>
|
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
|
{item.item_count !== undefined && (
|
|
<Box
|
|
component="span"
|
|
sx={{
|
|
fontSize: ".7rem",
|
|
color: "text.secondary",
|
|
bgcolor: "action.hover",
|
|
borderRadius: 1,
|
|
px: 0.75,
|
|
}}
|
|
>
|
|
{item.item_count}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|
|
return (
|
|
<Box
|
|
component="span"
|
|
sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
|
|
>
|
|
<Box component="span">{item.name}</Box>
|
|
{item.is_symlink && <SymlinkBadge target={item.link_target} />}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
export default function ProjectFileManager({
|
|
projectId,
|
|
projectNumber,
|
|
hasPermission,
|
|
hasNasFolder,
|
|
}: ProjectFileManagerProps) {
|
|
const alert = useAlert();
|
|
const queryClient = useQueryClient();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const isCancelling = useRef(false);
|
|
// dragenter/dragleave fire on every child boundary crossed during a drag.
|
|
// Counting enter/leave events keeps the overlay steady instead of flickering
|
|
// as the cursor moves over nested elements; the overlay only hides when the
|
|
// depth returns to 0 (the cursor truly left the drop zone).
|
|
const dragDepth = useRef(0);
|
|
|
|
const [currentPath, setCurrentPath] = 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<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<FileItem | null>(null);
|
|
const [deleting, setDeleting] = useState(false);
|
|
|
|
const canManage = hasPermission("projects.files");
|
|
|
|
const {
|
|
data: filesData,
|
|
isPending: filesLoading,
|
|
error: filesError,
|
|
} = useQuery(projectFilesOptions(projectId, currentPath));
|
|
const items = filesData?.items ?? [];
|
|
const breadcrumb = filesData?.breadcrumb ?? [""];
|
|
const fullPath = filesData?.full_path ?? "";
|
|
const errorMessage = filesError
|
|
? filesError.message || "Nepodařilo se načíst soubory"
|
|
: null;
|
|
|
|
const navigateTo = (path: string) => {
|
|
setNewFolderMode(false);
|
|
setRenamingItem(null);
|
|
setCurrentPath(path);
|
|
};
|
|
|
|
const handleBreadcrumbClick = (index: number) => {
|
|
if (index === 0) {
|
|
navigateTo("");
|
|
return;
|
|
}
|
|
const path = breadcrumb.slice(1, index + 1).join("/");
|
|
navigateTo(path);
|
|
};
|
|
|
|
const handleFolderClick = (folderName: string) => {
|
|
const path = currentPath ? `${currentPath}/${folderName}` : folderName;
|
|
navigateTo(path);
|
|
};
|
|
|
|
const handleUpload = async (files: FileList | null) => {
|
|
if (!files || files.length === 0) return;
|
|
|
|
setUploading(true);
|
|
let successCount = 0;
|
|
let errorMsg: string | null = null;
|
|
|
|
for (const file of Array.from(files)) {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
|
|
const params = new URLSearchParams({
|
|
action: "upload",
|
|
project_id: String(projectId),
|
|
});
|
|
if (currentPath) {
|
|
params.set("path", currentPath);
|
|
}
|
|
|
|
try {
|
|
const res = await apiFetch(
|
|
`${API_BASE}/project-files/upload?${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 (e) {
|
|
console.error("ProjectFileManager: upload failed", e);
|
|
errorMsg = "Chyba připojení";
|
|
}
|
|
}
|
|
|
|
setUploading(false);
|
|
|
|
if (successCount > 0) {
|
|
const msg =
|
|
successCount === 1
|
|
? "Soubor byl nahrán"
|
|
: `Nahráno ${successCount} souborů`;
|
|
alert.success(msg);
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["projects", String(projectId), "files"],
|
|
});
|
|
}
|
|
if (errorMsg) {
|
|
alert.error(errorMsg);
|
|
}
|
|
};
|
|
|
|
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
handleUpload(e.target.files);
|
|
e.target.value = "";
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
dragDepth.current = 0;
|
|
setDragOver(false);
|
|
if (!canManage) return;
|
|
handleUpload(e.dataTransfer.files);
|
|
};
|
|
|
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
if (!canManage) return;
|
|
dragDepth.current += 1;
|
|
setDragOver(true);
|
|
};
|
|
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
// Must preventDefault on dragover too, otherwise the browser rejects the
|
|
// drop. The overlay state is driven by enter/leave (see dragDepth).
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
if (!canManage) return;
|
|
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
|
if (dragDepth.current === 0) {
|
|
setDragOver(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateFolder = async () => {
|
|
const name = newFolderName.trim();
|
|
if (!name) return;
|
|
|
|
setCreatingFolder(true);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
action: "create_folder",
|
|
project_id: String(projectId),
|
|
});
|
|
const res = await apiFetch(`${API_BASE}/project-files?${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("");
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["projects", String(projectId), "files"],
|
|
});
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
|
}
|
|
} catch (e) {
|
|
console.error("ProjectFileManager: create folder failed", e);
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setCreatingFolder(false);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (item: FileItem) => {
|
|
const filePath = currentPath ? `${currentPath}/${item.name}` : item.name;
|
|
const params = new URLSearchParams({
|
|
action: "download",
|
|
project_id: String(projectId),
|
|
path: filePath,
|
|
});
|
|
try {
|
|
const res = await apiFetch(`${API_BASE}/project-files?${params}`);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => null);
|
|
alert.error(err?.error || "Chyba při stahování");
|
|
return;
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = item.name;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
console.error("ProjectFileManager: download failed", e);
|
|
alert.error("Chyba připojení");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return;
|
|
|
|
setDeleting(true);
|
|
const filePath = currentPath
|
|
? `${currentPath}/${deleteTarget.name}`
|
|
: deleteTarget.name;
|
|
try {
|
|
const params = new URLSearchParams({
|
|
project_id: String(projectId),
|
|
path: filePath,
|
|
});
|
|
const res = await apiFetch(`${API_BASE}/project-files?${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",
|
|
);
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["projects", String(projectId), "files"],
|
|
});
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se smazat");
|
|
}
|
|
} catch (e) {
|
|
console.error("ProjectFileManager: delete failed", e);
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setDeleting(false);
|
|
setDeleteTarget(null);
|
|
}
|
|
};
|
|
|
|
const handleRename = async (item: FileItem) => {
|
|
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: String(projectId),
|
|
});
|
|
const res = await apiFetch(`${API_BASE}/project-files?${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");
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["projects", String(projectId), "files"],
|
|
});
|
|
} else {
|
|
alert.error(data.error || "Nepodařilo se přejmenovat");
|
|
}
|
|
} catch (e) {
|
|
console.error("ProjectFileManager: rename failed", e);
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setRenamingItem(null);
|
|
}
|
|
};
|
|
|
|
const startRename = (item: FileItem) => {
|
|
setRenamingItem(item.name);
|
|
setRenameValue(item.name);
|
|
};
|
|
|
|
if (filesLoading && items.length === 0 && !errorMessage) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
if (errorMessage) {
|
|
return (
|
|
<Card>
|
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
|
Soubory
|
|
</Typography>
|
|
<EmptyState
|
|
icon={
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
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>
|
|
}
|
|
title={errorMessage}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const columns: DataColumn<FileItem>[] = [
|
|
{
|
|
key: "icon",
|
|
header: "",
|
|
width: 30,
|
|
align: "left",
|
|
render: (item) => (
|
|
<Box sx={{ textAlign: "center" }}>
|
|
{getFileIcon(item.type, item.extension)}
|
|
</Box>
|
|
),
|
|
},
|
|
{
|
|
key: "name",
|
|
header: "Název",
|
|
render: (item) =>
|
|
renamingItem === item.name ? (
|
|
<TextField
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
autoFocus
|
|
sx={{ maxWidth: 300 }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleRename(item);
|
|
}
|
|
if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
isCancelling.current = true;
|
|
setRenamingItem(null);
|
|
setRenameValue(item.name);
|
|
setTimeout(() => {
|
|
isCancelling.current = false;
|
|
}, 0);
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (isCancelling.current) {
|
|
return;
|
|
}
|
|
handleRename(item);
|
|
}}
|
|
/>
|
|
) : (
|
|
<FileNameCell item={item} onFolderClick={handleFolderClick} />
|
|
),
|
|
},
|
|
{
|
|
key: "size",
|
|
header: "Velikost",
|
|
width: 90,
|
|
render: (item) => (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{item.type === "file" ? item.size_formatted : "—"}
|
|
</Typography>
|
|
),
|
|
},
|
|
{
|
|
key: "modified",
|
|
header: "Změněno",
|
|
width: 120,
|
|
render: (item) => (
|
|
<Typography variant="caption" color="text.secondary">
|
|
{item.modified || "—"}
|
|
</Typography>
|
|
),
|
|
},
|
|
...(canManage
|
|
? [
|
|
{
|
|
key: "actions",
|
|
header: "Akce",
|
|
width: 100,
|
|
align: "right" as const,
|
|
render: (item: FileItem) => (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
gap: 0.5,
|
|
justifyContent: "flex-end",
|
|
}}
|
|
>
|
|
{item.type === "file" && (
|
|
<IconButton
|
|
size="small"
|
|
title="Stáhnout"
|
|
aria-label="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>
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
size="small"
|
|
title="Přejmenovat"
|
|
aria-label="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>
|
|
</IconButton>
|
|
<IconButton
|
|
size="small"
|
|
color="error"
|
|
title="Smazat"
|
|
aria-label="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>
|
|
</IconButton>
|
|
</Box>
|
|
),
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
|
|
return (
|
|
<Card>
|
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
|
Soubory
|
|
</Typography>
|
|
|
|
{/* Toolbar */}
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
gap: 1,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
gap: 0.25,
|
|
}}
|
|
>
|
|
{breadcrumb.map((segment, i) => (
|
|
<Box
|
|
component="span"
|
|
key={i}
|
|
sx={{ display: "inline-flex", alignItems: "center" }}
|
|
>
|
|
{i > 0 && (
|
|
<Box component="span" sx={{ mx: 0.25, color: "text.disabled" }}>
|
|
/
|
|
</Box>
|
|
)}
|
|
<Box
|
|
component="button"
|
|
type="button"
|
|
onClick={() => handleBreadcrumbClick(i)}
|
|
sx={{
|
|
background: "none",
|
|
border: "none",
|
|
p: 0,
|
|
font: "inherit",
|
|
cursor: "pointer",
|
|
color:
|
|
i === breadcrumb.length - 1
|
|
? "text.primary"
|
|
: "primary.main",
|
|
fontWeight: i === breadcrumb.length - 1 ? 600 : 400,
|
|
"&:hover": { textDecoration: "underline" },
|
|
}}
|
|
>
|
|
{i === 0 ? projectNumber : segment}
|
|
</Box>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
|
|
{fullPath && (
|
|
<Typography
|
|
variant="caption"
|
|
color="text.secondary"
|
|
title={fullPath}
|
|
sx={{
|
|
maxWidth: 320,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{fullPath}
|
|
</Typography>
|
|
)}
|
|
|
|
{canManage && (
|
|
<Box sx={{ display: "flex", gap: 1, ml: "auto" }}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="inherit"
|
|
onClick={() => {
|
|
setNewFolderMode(!newFolderMode);
|
|
setNewFolderName("");
|
|
}}
|
|
startIcon={
|
|
<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
|
|
size="small"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
startIcon={
|
|
uploading ? (
|
|
<CircularProgress size={14} color="inherit" />
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|
|
>
|
|
{uploading ? "Nahrávání..." : "Nahrát"}
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
style={{ display: "none" }}
|
|
onChange={handleFileInputChange}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{/* New folder input */}
|
|
{newFolderMode && (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
mb: 2,
|
|
}}
|
|
>
|
|
<TextField
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
placeholder="Název složky..."
|
|
autoFocus
|
|
sx={{ maxWidth: 320 }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleCreateFolder();
|
|
if (e.key === "Escape") {
|
|
setNewFolderMode(false);
|
|
setNewFolderName("");
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
size="small"
|
|
onClick={handleCreateFolder}
|
|
disabled={creatingFolder || !newFolderName.trim()}
|
|
>
|
|
{creatingFolder ? (
|
|
<CircularProgress size={14} color="inherit" />
|
|
) : (
|
|
"Vytvořit"
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
color="inherit"
|
|
onClick={() => {
|
|
setNewFolderMode(false);
|
|
setNewFolderName("");
|
|
}}
|
|
>
|
|
Zrušit
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Drop zone + table */}
|
|
<Box
|
|
onDrop={handleDrop}
|
|
onDragEnter={handleDragEnter}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
sx={{
|
|
position: "relative",
|
|
borderRadius: 2,
|
|
...(dragOver
|
|
? {
|
|
outline: "2px dashed",
|
|
outlineColor: "primary.main",
|
|
outlineOffset: -4,
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
{dragOver && (
|
|
<Box
|
|
sx={{
|
|
position: "absolute",
|
|
inset: 0,
|
|
zIndex: 2,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 1,
|
|
bgcolor: "action.hover",
|
|
color: "primary.main",
|
|
borderRadius: 2,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<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>
|
|
</Box>
|
|
)}
|
|
|
|
{items.length === 0 && !filesLoading ? (
|
|
<EmptyState
|
|
icon={
|
|
<svg
|
|
width="32"
|
|
height="32"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
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>
|
|
}
|
|
title={
|
|
hasNasFolder
|
|
? "Složka je prázdná"
|
|
: "Složka projektu zatím neexistuje"
|
|
}
|
|
description={
|
|
canManage && !hasNasFolder
|
|
? "Nahrání souboru ji automaticky vytvoří"
|
|
: undefined
|
|
}
|
|
/>
|
|
) : (
|
|
<DataTable
|
|
columns={columns}
|
|
rows={items}
|
|
rowKey={(item) => item.name}
|
|
/>
|
|
)}
|
|
</Box>
|
|
|
|
<ConfirmDialog
|
|
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"
|
|
confirmVariant="danger"
|
|
loading={deleting}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|