Files
app/src/admin/components/dashboard/DashSessions.tsx
BOHA 4674ff3389 fix(dashboard): equal-height cards — stretch + fill instead of ragged
The dashboard content grids used alignItems:"start", so cards in a row sized to
their own content (ragged bottoms). Switch to alignItems:"stretch" so each row
shares one height, and make the wrapped cards actually fill the cell:

- DashProfile / DashSessions: motion-div + Card height:100% (the inner card was
  shorter than its stretched cell).
- Right column (Aktivní projekty + Nabídky): cards flex:1 so the stacked pair
  fills the column to match the neighbouring single cards.

CSS-only; no logic change. tsc clean, lint 0, 669 tests pass. Verified live via
Playwright (3-col row cells equal at 607; bottom 2-col cards equal at 257).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 21:34:17 +02:00

291 lines
9.5 KiB
TypeScript

import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion, useReducedMotion } from "framer-motion";
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 { useAlert } from "../../context/AlertContext";
import { Card, Button, StatusChip, ConfirmDialog } from "../../ui";
import apiFetch from "../../utils/api";
import { formatSessionDate } from "../../utils/dashboardHelpers";
import { sessionsOptions, type Session } from "../../lib/queries/dashboard";
const API_BASE = "/api/admin";
interface DeleteModalState {
isOpen: boolean;
session: Session | null;
}
function getDeviceIcon(iconType?: string) {
switch (iconType) {
case "smartphone":
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
case "tablet":
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="4" y="2" width="16" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
default:
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
}
}
export default function DashSessions() {
const alert = useAlert();
const queryClient = useQueryClient();
const reduce = useReducedMotion();
const { data: sessions = [], isPending: sessionsLoading } =
useQuery(sessionsOptions());
const [deleteModal, setDeleteModal] = useState<DeleteModalState>({
isOpen: false,
session: null,
});
const [deleteAllModal, setDeleteAllModal] = useState(false);
const [deleting, setDeleting] = useState(false);
const handleDeleteSession = async () => {
if (!deleteModal.session) {
return;
}
const sessionId = deleteModal.session.id;
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions/${sessionId}`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteModal({ isOpen: false, session: null });
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success("Relace byla ukončena");
} else {
alert.error(data.error || "Nepodařilo se ukončit relaci");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
const handleDeleteAllSessions = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/sessions?action=all`, {
method: "DELETE",
});
const data = await response.json();
if (data.success) {
setDeleteAllModal(false);
queryClient.invalidateQueries({ queryKey: ["sessions"] });
alert.success(data.message || "Ostatní relace byly ukončeny");
} else {
alert.error(data.error || "Nepodařilo se ukončit relace");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
}
};
return (
<>
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.15 }}
style={{ height: "100%" }}
>
<Card sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1.5,
mb: 2,
}}
>
<Typography variant="h6">Přihlášená zařízení</Typography>
{sessions.filter((s) => !s.is_current).length > 0 && (
<Button
onClick={() => setDeleteAllModal(true)}
variant="outlined"
color="inherit"
size="small"
sx={{ flexShrink: 0 }}
>
Odhlásit ostatní
</Button>
)}
</Box>
{sessionsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
<CircularProgress size={28} />
</Box>
) : sessions.length === 0 ? (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: "center", py: 3 }}
>
Žádné aktivní relace
</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column" }}>
{sessions.map((session) => (
<Box
key={session.id}
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
"&:last-of-type": { borderBottom: 0 },
...(session.is_current
? { bgcolor: "action.hover", borderRadius: 2, px: 1 }
: {}),
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "text.secondary",
flexShrink: 0,
}}
>
{getDeviceIcon(session.device_info?.icon)}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{session.device_info?.browser} na{" "}
{session.device_info?.os}
</Typography>
{session.is_current && (
<StatusChip color="success" label="Aktuální" />
)}
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.75,
color: "text.secondary",
fontSize: "0.75rem",
}}
>
<span>{session.ip_address}</span>
<span>|</span>
<span>{formatSessionDate(session.created_at)}</span>
</Box>
</Box>
<Box sx={{ flexShrink: 0 }}>
{!session.is_current && (
<IconButton
onClick={() =>
setDeleteModal({ isOpen: true, session })
}
color="error"
size="small"
title="Ukončit relaci"
aria-label="Ukončit relaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</IconButton>
)}
</Box>
</Box>
))}
</Box>
)}
</Card>
</motion.div>
<ConfirmDialog
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, session: null })}
onConfirm={handleDeleteSession}
title="Ukončit relaci"
message={`Opravdu chcete ukončit relaci na zařízení "${deleteModal.session?.device_info?.browser} na ${deleteModal.session?.device_info?.os}"? Toto zařízení bude odhlášeno.`}
confirmText="Ukončit"
cancelText="Zrušit"
confirmVariant="danger"
loading={deleting}
/>
<ConfirmDialog
isOpen={deleteAllModal}
onClose={() => setDeleteAllModal(false)}
onConfirm={handleDeleteAllSessions}
title="Odhlásit ostatní zařízení"
message="Opravdu chcete ukončit všechny ostatní relace? Budete odhlášeni ze všech zařízení kromě tohoto."
confirmText="Odhlásit vše"
cancelText="Zrušit"
confirmVariant="primary"
loading={deleting}
/>
</>
);
}